Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-25 13:07:09 +00:00
41 changed files with 3356 additions and 137 deletions

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds `reference` to freight.audit_logs — the human identifier of the entity
* the action touched (booking reference, schedule number, train number, …),
* resolved at write time by the audit interceptor. `resource_id` stays the
* machine id; this column is what staff actually type into the search box.
*
* Production safety:
* - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+:
* no table rewrite, no long lock, existing rows read '' without being
* touched. Rows written before this migration keep '' permanently —
* capture starts from deploy, by design (no backfill).
* - Everything is IF NOT EXISTS so a hand-patched database converges
* instead of failing the deploy.
* - No existing column is altered and nothing is dropped: zero data-loss
* surface.
*
* The index is an expression index on upper(reference) with
* text_pattern_ops so the search endpoint's case-insensitive prefix match
* (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are
* excluded to keep it small — they are never searched for.
*/
export class AuditLogReference3690000000000 implements MigrationInterface {
name = 'AuditLogReference3690000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.audit_logs
ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT ''
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper
ON freight.audit_logs (upper(reference) text_pattern_ops)
WHERE reference <> ''
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Down discards every captured reference — acceptable only because down
// migrations are never run against production here.
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`);
await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`);
}
}

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none. * humanized handler name where a route has none.
* *
* Excludes the AI Assist and Account entities. * Excludes the AI Assist and Account entities.
* Generated from the controllers under src/ — 517 endpoints. * Generated from the controllers under src/ — 528 endpoints.
*/ */
/** [title, method, entity] for one auditable route. */ /** [title, method, entity] for one auditable route. */
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
@@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"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": ["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/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/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/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "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": ["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/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/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
@@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"],
"POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"],
"POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "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/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-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
@@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "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"], "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-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/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/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/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/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
@@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "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/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": ["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/: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/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"], "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"], "POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
@@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "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/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/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/: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/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/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/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
@@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "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"], "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"], "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"], // "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
// Driver // Driver
"POST /api/drivers": ["Create a new driver", "POST", "Driver"], "POST /api/drivers": ["Create a new driver", "POST", "Driver"],
@@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"], "POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
"POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"],
"POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"],
// Exchange Setting // Exchange Setting
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
@@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "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"], "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
// Operations Standard
"PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"],
// Operations Target
"POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"],
"PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"],
"DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"],
// Organization User // Organization User
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "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"], "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
@@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Two controllers register this same path; Nest serves whichever module loads first. // 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/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/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], "POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
// "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
// Service Type // Service Type
"POST /api/service-types": ["Create a service type", "POST", "Service Type"], "POST /api/service-types": ["Create a service type", "POST", "Service Type"],
@@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
// Shipping Line Booking // Shipping Line Booking
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], // "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
// Shipping Line Credit // Shipping Line Credit
@@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "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"], "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/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
"PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"],
"PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"],
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the 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"], "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"], "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
@@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "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/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/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/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": ["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/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/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/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": ["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/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"], "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"], "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/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/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/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-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
@@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "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"], "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"], "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"],
"PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"],
"POST /api/train-scheduling/schedules/:id/merge": ["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", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/merge": ["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", "POST", "Train Schedule"],
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
@@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "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"], "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"], "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-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"], "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"], "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { AuditLog } from './entities/audit-log.entity'; import { AuditLog } from './entities/audit-log.entity';
import type { AuditReferenceSource } from './audit-reference.registry';
export interface AuditLogQuery { export interface AuditLogQuery {
type?: string; type?: string;
@@ -12,6 +13,10 @@ export interface AuditLogQuery {
method?: string; method?: string;
isSuccess?: boolean; isSuccess?: boolean;
resourceId?: string; resourceId?: string;
reference?: string;
userName?: string;
title?: string;
q?: string;
from?: Date; from?: Date;
to?: Date; to?: Date;
skip: number; skip: number;
@@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
); );
} }
/**
* Resolve the human identifier for one entity row (`WHERE id = $1`).
*
* `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never
* from user input — so interpolating its table/column is safe; the id is
* bound as a parameter. Returns null when the row doesn't exist or the
* identifier column is empty.
*/
async lookupReference(
source: AuditReferenceSource,
id: string,
): Promise<string | null> {
const rows = await this.auditLogRepository.manager.query<
{ reference: string | null }[]
>(
`SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`,
[id],
);
return rows[0]?.reference || null;
}
/** /**
* Paginated, filtered read. Newest first — every index on this table is * Paginated, filtered read. Newest first — every index on this table is
* ordered `created_at DESC` to match. * ordered `created_at DESC` to match.
*
* Query builder rather than `findAndCount`: `q` needs an OR across four
* columns, and `reference` needs the `upper(...) LIKE` shape that matches
* the expression index — neither fits `FindOptionsWhere`.
*/ */
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
const where: FindOptionsWhere<AuditLog> = {}; const qb = this.auditLogRepository.createQueryBuilder('audit_log');
if (query.type) where.type = query.type; if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type });
if (query.userId) where.userId = query.userId; if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId });
if (query.method) where.method = query.method; if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method });
if (query.resourceId) where.resourceId = query.resourceId; if (query.resourceId) {
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId });
}
if (query.isSuccess !== undefined) {
qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess });
}
// Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper.
// The explicit <> '' repeats the index's partial predicate — without it the
// planner cannot prove the partial index applies and falls back to a scan.
if (query.reference) {
qb.andWhere("audit_log.reference <> ''").andWhere(
"upper(audit_log.reference) LIKE upper(:reference) || '%'",
{ reference: escapeLike(query.reference) },
);
}
if (query.userName) {
qb.andWhere('audit_log.user_name ILIKE :userName', {
userName: `%${escapeLike(query.userName)}%`,
});
}
if (query.title) {
qb.andWhere('audit_log.title ILIKE :title', {
title: `%${escapeLike(query.title)}%`,
});
}
// One search box across the columns staff actually search by.
// ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN
// indexes if the table grows past a few million rows.
if (query.q) {
const q = `%${escapeLike(query.q)}%`;
qb.andWhere(
`(audit_log.reference ILIKE :q
OR audit_log.resource_id ILIKE :q
OR audit_log.user_name ILIKE :q
OR audit_log.title ILIKE :q)`,
{ q },
);
}
// Date range: either bound may be supplied alone. // Date range: either bound may be supplied alone.
if (query.from && query.to) where.createdAt = Between(query.from, query.to); if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from });
else if (query.from) where.createdAt = MoreThanOrEqual(query.from); if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to });
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
return this.auditLogRepository.findAndCount({ return qb
where, .orderBy('audit_log.created_at', 'DESC')
order: { createdAt: 'DESC' }, .skip(query.skip)
skip: query.skip, .take(query.take)
take: query.take, .getManyAndCount();
});
} }
/** Distinct entity types present, for populating a filter dropdown. */ /** Distinct entity types present, for populating a filter dropdown. */
@@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
return rows.map((row) => row.type); return rows.map((row) => row.type);
} }
/** Distinct action titles present, for the action filter dropdown. */
async distinctTitles(): Promise<string[]> {
const rows = await this.auditLogRepository
.createQueryBuilder('audit_log')
.select('DISTINCT audit_log.title', 'title')
.orderBy('audit_log.title', 'ASC')
.getRawMany<{ title: string }>();
return rows.map((row) => row.title);
}
}
/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
} }

View File

@@ -0,0 +1,39 @@
/**
* Where each audited entity type keeps its human identifier — the value staff
* search by (booking reference, train number, invoice number).
*
* Used by `AuditService.record` for a single indexed primary-key lookup at
* write time. Types not listed simply get `reference = ''`; the lookup is
* best-effort and an audit row is never lost over it.
*
* Table and column names are static values from this file — never user input —
* so interpolating them into SQL is safe. Ids are always bound as parameters.
*/
export interface AuditReferenceSource {
/** Schema-qualified table holding the entity. */
readonly table: string;
/** Column with the human identifier. */
readonly column: string;
}
export const AUDIT_REFERENCE_SOURCES: Readonly<Record<string, AuditReferenceSource>> = {
Booking: { table: 'freight.bookings', column: 'reference' },
Contract: { table: 'freight.contracts', column: 'reference' },
// "Schedule" (reschedule module) and "Train Schedule" are the same table.
Schedule: { table: 'freight.train_schedules', column: 'reference' },
'Train Schedule': { table: 'freight.train_schedules', column: 'reference' },
Train: { table: 'freight.trains', column: 'train_number' },
// Train Build routes carry the train id in :id.
'Train Build': { table: 'freight.trains', column: 'train_number' },
Wagon: { table: 'freight.wagons', column: 'wagon_number' },
Locomotive: { table: 'freight.locomotives', column: 'code' },
'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' },
// Payment paths mostly carry an invoice id; the ones that don't (e.g.
// redirect-success/:bookingId) miss the lookup and fall back to ''.
Payment: { table: 'freight.invoices', column: 'invoice_number' },
Vehicle: { table: 'freight.vehicles', column: 'plate_number' },
Company: { table: 'freight.companies', column: 'name' },
};
/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -43,4 +43,13 @@ export class AuditController {
types(): Promise<string[]> { types(): Promise<string[]> {
return this.auditService.listTypes(); return this.auditService.listTypes();
} }
@Get('actions')
@BookingStaff(FREIGHT_PERMS.auditLog.view)
@ApiOperation({
summary: 'Distinct action titles present in the audit log (filter dropdown)',
})
actions(): Promise<string[]> {
return this.auditService.listActions();
}
} }

View File

@@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types';
import { AuditLog } from './entities/audit-log.entity'; import { AuditLog } from './entities/audit-log.entity';
import { AuditLogRepository } from './audit-log.repository'; import { AuditLogRepository } from './audit-log.repository';
import { AuditLogQueryDto } from './dto/audit-log-query.dto'; import { AuditLogQueryDto } from './dto/audit-log-query.dto';
import {
AUDIT_REFERENCE_SOURCES,
UUID_PATTERN,
} from './audit-reference.registry';
import { import {
buildPaginationMeta, buildPaginationMeta,
normalizePagination, normalizePagination,
@@ -25,6 +29,7 @@ export class AuditService {
*/ */
async record(entry: Partial<AuditLog>): Promise<void> { async record(entry: Partial<AuditLog>): Promise<void> {
try { try {
entry.reference = await this.resolveReference(entry.type, entry.resourceId);
await this.auditLogRepository.record(entry); await this.auditLogRepository.record(entry);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
@@ -35,6 +40,34 @@ export class AuditService {
} }
} }
/**
* Best-effort human identifier (booking reference, train number, …) for the
* entity the action touched — one primary-key lookup against the table
* registered for the type. Always returns a string: '' when the type has no
* registered source, the id isn't a uuid (template codes), the row is gone,
* or the lookup itself fails. A missing reference must never cost the audit
* row, so failures degrade to '' rather than throwing.
*/
private async resolveReference(
type: string | undefined,
resourceId: string | null | undefined,
): Promise<string> {
const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined;
if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return '';
try {
const reference = await this.auditLogRepository.lookupReference(source, resourceId);
return reference?.slice(0, 64) ?? '';
} catch (error) {
this.logger.warn(
`Reference lookup failed for ${type} ${resourceId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return '';
}
}
/** Paginated, filtered audit history, newest first. */ /** Paginated, filtered audit history, newest first. */
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> { async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
const { page, pageSize, skip, take } = normalizePagination(query); const { page, pageSize, skip, take } = normalizePagination(query);
@@ -53,6 +86,10 @@ export class AuditService {
userId: query.userId, userId: query.userId,
method: query.method, method: query.method,
resourceId: query.resourceId, resourceId: query.resourceId,
reference: query.reference,
userName: query.userName,
title: query.title,
q: query.q,
isSuccess: isSuccess:
query.isSuccess === undefined ? undefined : query.isSuccess === 'true', query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
from, from,
@@ -68,4 +105,9 @@ export class AuditService {
async listTypes(): Promise<string[]> { async listTypes(): Promise<string[]> {
return this.auditLogRepository.distinctTypes(); return this.auditLogRepository.distinctTypes();
} }
/** Distinct action titles, for the action filter dropdown. */
async listActions(): Promise<string[]> {
return this.auditLogRepository.distinctTitles();
}
} }

View File

@@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto {
@MaxLength(64) @MaxLength(64)
resourceId?: string; resourceId?: string;
@ApiPropertyOptional({
description:
'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.',
example: 'S-2026-00045',
})
@IsOptional()
@IsString()
@MaxLength(64)
reference?: string;
@ApiPropertyOptional({
description: 'Staff name, case-insensitive substring match.',
example: 'Mulu',
})
@IsOptional()
@IsString()
@MaxLength(150)
userName?: string;
@ApiPropertyOptional({
description: 'Action title, case-insensitive substring match.',
example: 'Cancel booking',
})
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@ApiPropertyOptional({
description:
'Free-text search across reference, resource id, staff name and action title.',
example: 'B-2026-00120',
})
@IsOptional()
@IsString()
@MaxLength(100)
q?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Filter by outcome: true = succeeded, false = failed.', description: 'Filter by outcome: true = succeeded, false = failed.',
}) })

View File

@@ -86,6 +86,20 @@ export class AuditLog {
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
resourceId?: string | null; resourceId?: string | null;
/**
* Human identifier of the affected record — booking reference, schedule
* number, train number — resolved at write time from
* `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box;
* `resourceId` stays the machine id.
*
* `''` (never NULL) when the entity type has no registered source, the
* lookup found nothing, or the row predates the column. Empty string keeps
* search SQL to one shape and matches how pre-existing rows read after the
* metadata-only migration.
*/
@Column({ name: 'reference', type: 'varchar', length: 64, default: '' })
reference!: string;
/** /**
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
* are reduced to `{ __file, originalName, mimeType, size }` descriptors — * are reduced to `{ __file, originalName, mimeType, size }` descriptors —

View File

@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
expect(cut.weightTons).toBeCloseTo(62.625, 3); expect(cut.weightTons).toBeCloseTo(62.625, 3);
}); });
}); });
/**
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
* must pick the consolidation partner — no partner, no rebook; a partner
* already paired elsewhere is refused.
*/
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
const units = Array.from({ length: 3 }, (_, i) => ({
containerSize: '20ft',
containerNumber: `CONT${i}`,
sealNumber: null,
vgmTons: 10,
isHazardous: false,
isReefer: false,
}));
const row = {
id: 'wc1',
bookingId: 'b1',
status: 'CREDIT_AVAILABLE',
creditAmount: 100,
cancelledQuantities: { bySize: { '20ft': 3 }, units },
};
const source = {
id: 'b1',
contractId: 'c1',
paymentCurrency: 'USD',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
};
const makeSvc = (partner?: unknown) => {
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
string,
unknown
> & {
rebook(id: string, dto: unknown): Promise<unknown>;
};
svc.repo = { findById: async () => row };
svc.bookingsRepository = {
findById: async () => source,
findByIdWithFiles: async () => partner ?? null,
};
return svc;
};
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
await expect(
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.toThrow(/pick a consolidation partner/i);
});
it('refuses a partner that already shares a wagon', async () => {
const paired = {
id: 'p1',
reference: 'BK-1',
status: 'SUBMITTED',
consolidationPartnerId: 'someone-else',
};
await expect(
makeSvc(paired).rebook('wc1', {
scheduledDate: '2026-09-01',
partnerBookingId: 'p1',
}),
).rejects.toThrow(/already shares a wagon/i);
});
});

View File

@@ -7,7 +7,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { ExchangeService } from '@edr/api-common'; import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In, IsNull } from 'typeorm'; import { DataSource, EntityManager, In, IsNull } from 'typeorm';
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
@Inject(forwardRef(() => FirstMileService)) @Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService, private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService, private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
) {} ) {}
// ── T1: request ──────────────────────────────────────────────────────────── // ── T1: request ────────────────────────────────────────────────────────────
@@ -782,6 +783,26 @@ export class BookingWagonCancellationService {
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
// Same currency as the source booking — the credit is in it. // Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined; createDto.paymentCurrency = source.paymentCurrency ?? undefined;
// An odd-20ft credit shares a wagon again on rebook. GL picks who — never
// the auto-matcher (it could claim a partner behind GL's back), so the
// create below runs with auto-consolidation off and the chosen partner is
// linked once the booking exists and is PAID.
const oddFt20 = this.creditFt20(row) % 2 === 1;
let partner: Booking | null = null;
if (oddFt20) {
createDto.skipAutoConsolidation = true;
if (!dto.partnerBookingId) {
throw new BadRequestException(
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
);
}
partner = await this.loadRebookPartner(
source,
dto.partnerBookingId,
dto.scheduledDate,
);
}
const created = await this.contractBooking.createUnderContract( const created = await this.contractBooking.createUnderContract(
source.contractId, source.contractId,
createDto, createDto,
@@ -814,12 +835,19 @@ export class BookingWagonCancellationService {
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, `First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
); );
} }
try { if (partner) {
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); // Consolidated rebook: never allocate the half-wagon booking alone. It
} catch (err) { // rides PAID and the batch engine settles the pair atomically once the
this.logger.error( // partner's own invoice is paid.
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, await this.pairRebookedBooking(newBookingId, partner);
); } else {
try {
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
} catch (err) {
this.logger.error(
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
} }
const updated = (await this.repo.update(row.id, { const updated = (await this.repo.update(row.id, {
@@ -837,6 +865,142 @@ export class BookingWagonCancellationService {
return { cancellation: updated, bookingId: newBookingId }; return { cancellation: updated, bookingId: newBookingId };
} }
/** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */
private creditFt20(row: BookingWagonCancellation): number {
return Object.entries(row.cancelledQuantities?.bySize ?? {})
.filter(([size]) => sizeFtOf(size) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0);
}
/**
* Partner candidates for rebooking an odd-20ft credit — what the GL rebook
* form lists. Empty when the credit is even (no shared wagon) or spent.
*/
async rebookPartnerCandidates(
cancellationId: string,
scheduledDate: string,
): Promise<
Array<{
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}>
> {
const row = await this.mustFind(cancellationId);
if (row.status !== 'CREDIT_AVAILABLE') return [];
if (this.creditFt20(row) % 2 === 0) return [];
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) return [];
const rows = await this.bookingsRepository.findRebookConsolidationCandidates(
source,
new Date(scheduledDate),
);
return rows.map((b) => ({
id: b.id,
reference: b.reference,
companyName: b.company?.name ?? null,
status: b.status,
scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null,
ft20Quantity: (b.bookingContainers ?? [])
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
}));
}
/** The GL-picked partner, validated to actually fit the rebooked shared wagon. */
private async loadRebookPartner(
source: Booking,
partnerId: string,
scheduledDate: string,
): Promise<Booking> {
const partner = await this.bookingsRepository.findByIdWithFiles(partnerId);
if (!partner) {
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
}
if (partner.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
throw new BadRequestException(
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
);
}
if (
partner.originYardId !== source.originYardId ||
partner.destinationYardId !== source.destinationYardId ||
partner.tradeDirection !== source.tradeDirection
) {
throw new BadRequestException(
`Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`,
);
}
const eatDay = (d: Date | string) =>
new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) {
throw new BadRequestException(
`Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`,
);
}
const ft20 = (partner.bookingContainers ?? [])
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20 % 2 !== 1) {
throw new BadRequestException(
`Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`,
);
}
return partner;
}
/**
* Link the rebooked (already PAID) booking with the GL-picked partner. A
* parked partner is resumed the way pairConsolidation would resume it —
* but only the partner: the rebooked side's PAID status must survive, so
* the link is written directly. The paired event then runs the partner's
* deferred contract finalize (invoice → pay window); the shared wagon
* boards once that invoice is paid.
*/
private async pairRebookedBooking(
newBookingId: string,
partner: Booking,
): Promise<void> {
// ponytail: validate-then-link without a row lock — a concurrent claim in
// this window loses silently; move to pairConsolidationIfUnpaired-style
// locking if it ever bites.
const fresh = await this.dataSource.getRepository(Booking).findOne({
where: { id: partner.id },
select: { id: true, consolidationPartnerId: true, status: true },
});
if (!fresh || fresh.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`,
);
}
if (fresh.status === 'PENDING_CONSOLIDATION') {
await this.dataSource.getRepository(Booking).update(partner.id, {
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
});
}
await this.bookingsRepository.linkConsolidationPartners(
newBookingId,
partner.id,
);
this.events.emit('booking.consolidation.paired', {
bookingIds: [partner.id],
});
this.notifyCustomer(
partner,
'Consolidation partner found',
`${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`,
);
}
// ── History ──────────────────────────────────────────────────────────────── // ── History ────────────────────────────────────────────────────────────────
list(filter: WagonCancellationListFilter) { list(filter: WagonCancellationListFilter) {

View File

@@ -725,6 +725,30 @@ export class BookingsController {
return this.wagonCancellationService.withdraw(cancellationId); return this.wagonCancellationService.withdraw(cancellationId);
} }
@Get("wagon-cancellations/:cancellationId/rebook-partners")
@ApiOperation({
summary:
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
})
async listRebookPartners(
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
@Query("scheduledDate") scheduledDate: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
if (!scheduledDate) {
throw new BadRequestException("scheduledDate is required.");
}
return this.wagonCancellationService.rebookPartnerCandidates(
cancellationId,
scheduledDate,
);
}
@Post("wagon-cancellations/:cancellationId/rebook") @Post("wagon-cancellations/:cancellationId/rebook")
@ApiOperation({ @ApiOperation({
summary: summary:

View File

@@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository<Booking> {
}); });
} }
/**
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
* odd-20ft bookings on the same route/direction riding the requested day —
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
* GL picks who shares the rebooked wagon whatever the contract kind.
*/
async findRebookConsolidationCandidates(
booking: Booking,
scheduledDate: Date,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
})
.andWhere('b.status IN (:...statuses)', {
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
// Same EAT booking day as the rebook — the pair shares one physical
// wagon, so it must board one train.
.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: scheduledDate },
)
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return false;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
return ft20 % 2 === 1;
});
}
/** /**
* Find another booking whose container quantity complements this one to fill whole wagon(s) * Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever * (same route, same container type, partial wagon on both sides). Only 20ft lines ever

View File

@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => RebookContainerLineDto) @Type(() => RebookContainerLineDto)
containers?: RebookContainerLineDto[]; containers?: RebookContainerLineDto[];
@ApiPropertyOptional({
description:
'Required when the credit carries an odd 20ft count: the odd-20ft booking ' +
'GL picked to share the rebooked wagon (see the rebook-partners endpoint).',
})
@IsOptional()
@IsUUID()
partnerBookingId?: string;
} }
export class FilterWagonCancellationsDto { export class FilterWagonCancellationsDto {

View File

@@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => {
}); });
}); });
}); });
/**
* The customer's shipment request is the order: GL may not change its container
* sizes/quantities or billing currency at completion — only per-unit details.
*/
describe('ContractBookingService — shipment-request lock at completion', () => {
type WithAssert = {
assertMatchesShipmentRequest(
bookingId: string,
dto: {
paymentCurrency?: string;
containers?: Array<{ containerSize: string; quantity: number }>;
bulkLines?: Array<{ cargoWeightTons?: number }>;
},
): Promise<void>;
};
const serviceWithRequest = (request: unknown): WithAssert => {
const svc = Object.create(ContractBookingService.prototype) as WithAssert & {
dataSource: unknown;
};
svc.dataSource = {
getRepository: () => ({ findOne: async () => request }),
};
return svc;
};
const request = {
paymentCurrency: 'USD',
requestedLines: {
containers: [
{ containerSize: '20ft', quantity: 2 },
{ containerSize: '40ft', quantity: 1 },
],
},
};
it('accepts the exact requested quantities and currency', async () => {
await expect(
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'USD',
containers: [
{ containerSize: '40ft', quantity: 1 },
{ containerSize: '20ft', quantity: 2 },
],
}),
).resolves.toBeUndefined();
});
it('rejects changed quantities', async () => {
await expect(
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'USD',
containers: [
{ containerSize: '20ft', quantity: 4 },
{ containerSize: '40ft', quantity: 1 },
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a changed billing currency', async () => {
await expect(
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'ETB',
containers: [
{ containerSize: '20ft', quantity: 2 },
{ containerSize: '40ft', quantity: 1 },
],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('is a no-op without a linked request', async () => {
await expect(
serviceWithRequest(null).assertMatchesShipmentRequest('b1', {
paymentCurrency: 'ETB',
containers: [{ containerSize: '20ft', quantity: 9 }],
}),
).resolves.toBeUndefined();
});
});

View File

@@ -36,6 +36,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util'; import { hasFreightPermission } from '../../common/freight-permission.util';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity'; import { ContractRoute } from './entities/contract-route.entity';
import { import {
@@ -395,6 +396,10 @@ export class ContractBookingService {
if ( if (
withContainers && withContainers &&
freightType === 'CONTAINER' && freightType === 'CONTAINER' &&
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
// auto-claim (or park) it here behind GL's back.
!dto.skipAutoConsolidation &&
(await this.consolidationService.needsConsolidationFromBooking( (await this.consolidationService.needsConsolidationFromBooking(
withContainers, withContainers,
)) ))
@@ -863,6 +868,12 @@ export class ContractBookingService {
direction: contract.tradeDirection ?? null, direction: contract.tradeDirection ?? null,
}); });
// The customer's shipment request is the order: sizes, quantities and
// billing currency are theirs — GL enters everything else. Both halves of a
// consolidated pair pass through here, so each is checked against its OWN
// request.
await this.assertMatchesShipmentRequest(booking.id, dto);
const freightType = contract.freightType; const freightType = contract.freightType;
let hasCargo = let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 || (booking.bookingContainers?.length ?? 0) > 0 ||
@@ -1052,6 +1063,72 @@ export class ContractBookingService {
return { booking: completed, warnings }; return { booking: completed, warnings };
} }
/**
* The linked shipment request (customs Path B) is the customer's order:
* container sizes + quantities and the billing currency are the customer's
* choices, and GL may not change them at completion — only per-unit details
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
* are checked only when the payload restates cargo (a day-only resubmit keeps
* the already-validated persisted cargo).
*/
private async assertMatchesShipmentRequest(
bookingId: string,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const request = await this.dataSource.getRepository(BookingRequest).findOne({
where: { createdBookingId: bookingId },
});
if (!request) return;
const lines = request.requestedLines ?? {};
if (request.paymentCurrency) {
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
throw new BadRequestException(
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
);
}
dto.paymentCurrency = request.paymentCurrency;
}
if (dto.containers?.length && lines.containers?.length) {
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
const map = new Map<number, number>();
for (const row of rows) {
const ft = parseInt(String(row.containerSize), 10);
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
}
return map;
};
const requested = byFt(lines.containers);
const given = byFt(dto.containers);
const same =
requested.size === given.size &&
[...requested].every(([ft, qty]) => given.get(ft) === qty);
if (!same) {
const summary = [...requested]
.map(([ft, qty]) => `${qty} × ${ft}ft`)
.join(', ');
throw new BadRequestException(
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
);
}
}
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
const givenTons = dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons || 0),
0,
);
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
throw new BadRequestException(
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
);
}
}
}
/** /**
* Search for a complementary partner for a parked-eligible drawdown, pair it or * Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to. * park it in PENDING_CONSOLIDATION with the resume status it should return to.

View File

@@ -566,8 +566,9 @@ export class TrainSchedulingController {
dispatchSchedule( dispatchSchedule(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: DispatchScheduleDto, @Body() dto: DispatchScheduleDto,
@CurrentUser() user: AuthUserPayload,
) { ) {
return this.trainSchedulingService.dispatchSchedule(id, dto); return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user));
} }
@Get("intercity/bookings") @Get("intercity/bookings")

View File

@@ -1,11 +1,13 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { TrainCheckpointKind } from '@edr/types'; import { TrainCheckpointKind } from '@edr/types';
import { import {
IsArray,
IsEnum, IsEnum,
IsInt, IsInt,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
Min, Min,
} from 'class-validator'; } from 'class-validator';
@@ -113,4 +115,20 @@ export class DispatchScheduleDto {
@IsOptional() @IsOptional()
@IsISO8601() @IsISO8601()
actualDepartureAt?: string; actualDepartureAt?: string;
/**
* Loading is a manual staff decision. When present, only these bookings are
* auto-loaded at the origin; every other unloaded origin boarder is left
* behind — deallocated from its wagon and returned to the booking pool.
* Absent (older clients) = load every origin boarder, the historic behavior.
*/
@ApiProperty({
required: false,
description:
'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.',
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
loadedBookingIds?: string[];
} }

View File

@@ -2370,16 +2370,22 @@ export class TrainSchedulingService {
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
} }
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
if (!link) { if (!link) {
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
} }
const booking = await this.bookingsRepository.findById(bookingId); const booking = await this.bookingsRepository.findById(bookingId);
// A dispatched train may still shed a booking staff left behind at its
// boarding yard (dispatch dialog / log-pass "leave") — but never one whose
// cargo is actually on the train.
const leftBehindWhileDispatched =
schedule.status === 'DISPATCHED' &&
!booking?.loadedAt &&
booking?.status !== 'IN_TRANSIT';
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
if (booking?.isGovernment) { if (booking?.isGovernment) {
throw new BadRequestException( throw new BadRequestException(
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
@@ -2447,6 +2453,21 @@ export class TrainSchedulingService {
for (const slot of survivingSlots) { for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? []; const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) { if (slotAllocations.length === 0) {
// A dispatched train pinned its wagons (ASSIGNED + schedule id) at
// departure — freeing the slot must also free the physical wagon, or
// the checkpoint position-fix keeps dragging it along the corridor.
if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) {
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: slot.physicalWagonId } });
if (wagon && wagon.currentTrainScheduleId === scheduleId) {
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
});
}
}
await manager.getRepository(TrainSetWagon).delete(slot.id); await manager.getRepository(TrainSetWagon).delete(slot.id);
continue; continue;
} }
@@ -2476,8 +2497,10 @@ export class TrainSchedulingService {
// Freed wagons may un-full the train — re-derive the window status (this // Freed wagons may un-full the train — re-derive the window status (this
// also revives a DONE window pre-departure so the freed space is bookable // also revives a DONE window pre-departure so the freed space is bookable
// again for import/export). // again for import/export). A dispatched train's window stays CLOSED.
await this.bookingBatchService?.refreshWindowStatus(scheduleId); if (schedule.status !== 'DISPATCHED') {
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
}
await this.trainCompositionRemovalLogRepository.create({ await this.trainCompositionRemovalLogRepository.create({
scheduleId, scheduleId,
@@ -2842,14 +2865,35 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
} }
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched'); throw new BadRequestException('Only SCHEDULED trains can be dispatched');
} }
// Loading is a manual staff decision: when the dispatch dialog sends the
// checked list, every other unloaded origin boarder is left behind —
// deallocated from its wagon and returned to the booking pool — so the
// origin auto-load below only ever touches confirmed cargo. Government
// bookings cannot be unassigned and keep the historic auto-load.
if (dto.loadedBookingIds) {
const keep = new Set(dto.loadedBookingIds);
const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId);
const leftBehind = candidates.filter((id) => !keep.has(id));
for (const bookingId of leftBehind) {
await this.unassignBooking(scheduleId, bookingId, userId);
}
if (leftBehind.length) {
// Unassign deleted allocations and slots — reload the graph dispatch works on.
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!reloaded) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
schedule = reloaded;
}
}
// Staff may record the departure after the fact — past is fine, future is not. // Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time'); this.assertNotFuture(now, 'Departure time');
@@ -3071,6 +3115,34 @@ export class TrainSchedulingService {
}); });
} }
/**
* Origin boarders the dispatch dialog decides over: unloaded (no journey
* load, no workspace LOADED flag), boardable, non-government. Boardable is
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
* (their charge sits on the credit ledger) yet ride from accept.
*/
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
): Promise<string[]> {
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT b.id
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id
WHERE tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false
AND (b.status = 'PAID'
OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
[scheduleId, originYardId],
);
return rows.map((r) => r.id);
}
async getImportDjiboutiOperation(scheduleId: string) { async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -9866,6 +9938,9 @@ export class TrainSchedulingService {
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
isGovernment: Boolean(sb.booking?.isGovernment), isGovernment: Boolean(sb.booking?.isGovernment),
// Shipping-line bookings never prepay (credit ledger) — the dispatch
// dialog needs this to know FULLY_EXECUTED means boardable for them.
shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null,
})) ?? [], })) ?? [],
// Ordered corridor stops (route milestones; falls back to the two // Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs. // endpoints) — lets the UI draw per-segment occupancy and label legs.

View File

@@ -88,6 +88,7 @@ import {
import { import {
ConsolidationPartnerPanel, ConsolidationPartnerPanel,
emptyPartnerLine, emptyPartnerLine,
emptyPartnerUnit,
} from "./gl-booking-form/ConsolidationPartnerPanel"; } from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
@@ -250,6 +251,17 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId), enabled: Boolean(requestId),
}); });
// The shipment request is the customer's order: container sizes/quantities
// and the billing currency are the customer's choices and stay read-only —
// GL enters only per-unit details (numbers, seals, VGM, handling). The
// server enforces the same on completion.
const requestContainersLocked = Boolean(
bookingRequest?.requestedLines?.containers?.length,
);
const requestBulkLocked =
bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null;
const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency);
// The expired booking a Rebook is copying from (its cargo seeds the form). // The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({ const { data: copyFromBooking } = useQuery({
queryKey: ["rebook-copy-from", copyFromParam], queryKey: ["rebook-copy-from", copyFromParam],
@@ -328,6 +340,39 @@ export default function GlCreateBookingForm() {
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null); const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]); const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
// The partner is its own customer: if a shipment request created it, that
// request locks the partner's quantities and billing currency the same way
// this booking's request locks this side (server enforces both halves).
const { data: partnerContractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", partner?.contractId],
queryFn: () => contractsService.listBookingRequests(partner!.contractId!),
enabled: Boolean(partner?.contractId),
});
const partnerRequest =
(partner &&
partnerContractRequests?.find(
(r) => r.createdBookingId === partner.id,
)) ||
null;
const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length);
// Seed (and lock) the partner's lines from its request once it loads.
useEffect(() => {
const requested = partnerRequest?.requestedLines?.containers;
if (!partner || !requested?.length) return;
setPartnerLines(
requested.map((c) => ({
containerSize: c.containerSize,
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit),
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partner?.id, partnerRequest?.id]);
const seededRef = useRef(false); const seededRef = useRef(false);
const returnSeededRef = useRef(false); const returnSeededRef = useRef(false);
@@ -490,6 +535,14 @@ export default function GlCreateBookingForm() {
if (bookingRequest.contractRouteId) if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId); setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes); if (bookingRequest.notes) setNotes(bookingRequest.notes);
// Currency is the customer's choice on the request — seed it here; the
// selector below is disabled while the request specifies one.
if (
bookingRequest.paymentCurrency === "USD" ||
bookingRequest.paymentCurrency === "ETB"
) {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
}, [bookingRequest, prefilled]); }, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight / // Rebook seed: copy the source booking's container lines once. (Bulk weight /
@@ -1114,7 +1167,13 @@ export default function GlCreateBookingForm() {
if (!partner || !consolidationActive) return null; if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = { const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency: effectiveCurrency, // The partner's customer chose its own currency on its shipment request;
// only a partner without a request falls back to this booking's currency.
paymentCurrency:
partnerRequest?.paymentCurrency === "USD" ||
partnerRequest?.paymentCurrency === "ETB"
? partnerRequest.paymentCurrency
: effectiveCurrency,
...(scheduledDate ...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() } ? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}), : {}),
@@ -1663,6 +1722,12 @@ export default function GlCreateBookingForm() {
label="Quantity *" label="Quantity *"
min={0} min={0}
value={line.quantity} value={line.quantity}
disabled={requestContainersLocked}
description={
requestContainersLocked
? "Requested by the customer — quantity cannot be changed."
: undefined
}
error={ error={
showErrors showErrors
? (lineErrors[lineIdx]?.quantity ?? ? (lineErrors[lineIdx]?.quantity ??
@@ -1924,6 +1989,7 @@ export default function GlCreateBookingForm() {
showReefer={Boolean(contract.isReefer)} showReefer={Boolean(contract.isReefer)}
showErrors={showErrors} showErrors={showErrors}
error={partnerError} error={partnerError}
lockQuantities={partnerLocked}
/> />
</> </>
) : null} ) : null}
@@ -1946,6 +2012,12 @@ export default function GlCreateBookingForm() {
placeholder="e.g. 1200" placeholder="e.g. 1200"
min={0} min={0}
step={0.01} step={0.01}
disabled={requestBulkLocked}
description={
requestBulkLocked
? "Requested by the customer — quantity cannot be changed."
: undefined
}
value={bulk.cargoWeightTons} value={bulk.cargoWeightTons}
error={ error={
showErrors && bulkUom === "PER_TON" showErrors && bulkUom === "PER_TON"
@@ -2147,14 +2219,16 @@ export default function GlCreateBookingForm() {
Billing currency Billing currency
</Text> </Text>
<Text size="xs" c="dimmed" mb={8}> <Text size="xs" c="dimmed" mb={8}>
{isImport {requestCurrencyLocked
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." ? "The customer chose the billing currency on the shipment request — it cannot be changed."
: "Shipments are invoiced in ETB."} : isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
</Text> </Text>
<CurrencySelector <CurrencySelector
value={isImport ? paymentCurrency : "ETB"} value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency} onChange={setPaymentCurrency}
disabled={!isImport} disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport} allowUsd={isImport}
error={currencyError} error={currencyError}
/> />

View File

@@ -86,6 +86,11 @@ interface Props {
/** Surface field errors only after the operator tried to continue. */ /** Surface field errors only after the operator tried to continue. */
showErrors: boolean; showErrors: boolean;
error?: string; error?: string;
/**
* The partner's shipment request fixed its sizes/quantities — the quantity
* fields render read-only and GL enters only per-unit details.
*/
lockQuantities?: boolean;
} }
export function ConsolidationPartnerPanel({ export function ConsolidationPartnerPanel({
@@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({
showReefer, showReefer,
showErrors, showErrors,
error, error,
lockQuantities,
}: Props) { }: Props) {
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => { const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
onLinesChange( onLinesChange(
@@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({
label="Quantity *" label="Quantity *"
min={0} min={0}
value={line.quantity} value={line.quantity}
disabled={lockQuantities}
description={
lockQuantities
? "Requested by the partner's customer — quantity cannot be changed."
: undefined
}
onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })} onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })}
// Sync off the typed value, not the captured `line` — that snapshot // Sync off the typed value, not the captured `line` — that snapshot
// still holds the pre-edit quantity and would write it back. // still holds the pre-edit quantity and would write it back.

View File

@@ -115,6 +115,7 @@ export function LogPassYardWorkModal({
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth(); const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
const [justLogged, setJustLogged] = useState(false); const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact). // When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null); const [passAt, setPassAt] = useState<Date | null>(null);
@@ -134,6 +135,10 @@ export function LogPassYardWorkModal({
api.trainScheduling.recordCheckpoint.mutationOptions(), api.trainScheduling.recordCheckpoint.mutationOptions(),
); );
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions()); const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
// "Leave behind": the cargo is not on the train — unassign frees its wagons
// and returns the booking to the pool for a later schedule. Reversible (the
// booking can be re-assigned), so no extra confirm step.
const leave = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId); const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? []; const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
@@ -196,6 +201,28 @@ export function LogPassYardWorkModal({
); );
}; };
const doLeave = (row: YardWorkBookingRow) => {
leave.mutate(
{ id: scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} left behind`,
description:
"Removed from this train — wagons freed, booking returned to the pool for a later schedule.",
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not leave booking behind",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const hasWork = boarders.length > 0 || arrivals.length > 0; const hasWork = boarders.length > 0 || arrivals.length > 0;
return ( return (
@@ -355,30 +382,54 @@ export function LogPassYardWorkModal({
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{!row.loadedAt ? ( {!row.loadedAt ? (
<Tooltip <Group gap={6} wrap="nowrap">
label={ <Tooltip
!canLoad label={
? "You don't have permission to load cargo" !canLoad
: !logged ? "You don't have permission to load cargo"
? "Log the pass first — the train must be at this yard" : !logged
: !row.canLoad ? "Log the pass first — the train must be at this yard"
? "Booking is not ready to load (payment pending)" : !row.canLoad
: "Confirm cargo loaded onto the train" ? "Booking is not ready to load (payment pending)"
} : "Confirm cargo loaded onto the train"
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
} }
onClick={() => doLoad(row)}
> >
Load <Button
</Button> size="compact-xs"
</Tooltip> variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}
onClick={() => doLoad(row)}
>
Load
</Button>
</Tooltip>
<Tooltip
label={
row.isGovernment
? "Government bookings cannot be removed from a train"
: !canLeave
? "You don't have permission to remove bookings"
: "Cargo is not on the train — free its wagons and return the booking to the pool"
}
>
<Button
size="compact-xs"
variant="light"
color="red"
disabled={!canLeave || row.isGovernment}
loading={
leave.isPending && leave.variables?.bookingId === row.id
}
onClick={() => doLeave(row)}
>
Leave
</Button>
</Tooltip>
</Group>
) : null} ) : null}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>

View File

@@ -1,7 +1,9 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Badge, Badge,
Button,
Card, Card,
Code, Code,
Group, Group,
@@ -42,6 +44,26 @@ const OUTCOME_OPTIONS = [
{ value: "false", label: "Failed" }, { value: "false", label: "Failed" },
]; ];
/**
* Entity type → detail page for that record. Drives the row's "Go" button;
* types without a detail page (Wagon, Locomotive, …) simply have no button.
*/
const ENTITY_ROUTES: Record<string, (id: string) => string> = {
Booking: (id) => `/dashboard/booking-requests/${id}`,
Contract: (id) => `/dashboard/contract-requests/${id}`,
Schedule: (id) => `/dashboard/operations/train-scheduling-v2/${id}`,
"Train Schedule": (id) => `/dashboard/operations/train-scheduling-v2/${id}`,
Train: (id) => `/dashboard/trains/${id}`,
"Train Build": (id) => `/dashboard/trains/${id}`,
"EIMS Invoice": (id) => `/dashboard/invoices/${id}`,
Payment: (id) => `/dashboard/invoices/${id}`,
Vehicle: (id) => `/dashboard/vehicles/${id}`,
Company: (id) => `/dashboard/customers/${id}`,
};
const entityRoute = (log: AuditLog): string | null =>
log.resourceId ? (ENTITY_ROUTES[log.type]?.(log.resourceId) ?? null) : null;
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */ /** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`; const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`; const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
@@ -49,13 +71,20 @@ const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString(); const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => { const AuditLogsPage = () => {
const navigate = useNavigate();
// Entity pages deep-link here as /dashboard/audit-logs?type=Booking&resourceId=<id>
// to show one record's full history with the filters already applied.
const [searchParams] = useSearchParams();
// Server-side filters. Unlike most freight lists (which filter an // Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and // already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API. // grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState(""); const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [dateFrom, setDateFrom] = useState<string | null>(null); const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null); const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(null); const [type, setType] = useState<string | null>(searchParams.get("type"));
const [resourceId] = useState<string | null>(searchParams.get("resourceId"));
const [action, setAction] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null); const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null); const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null); const [selected, setSelected] = useState<AuditLog | null>(null);
@@ -69,13 +98,16 @@ const AuditLogsPage = () => {
type: type ?? undefined, type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined, method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true", isSuccess: outcome === null ? undefined : outcome === "true",
// The API filters by record id; the search box is the natural place to // Free-text: matches reference (booking/schedule/train number), record
// paste one when tracing what happened to a specific contract/booking. // id, staff name and action title server-side.
resourceId: search.trim() || undefined, q: search.trim() || undefined,
title: action ?? undefined,
// Set only via deep link from an entity page's "History" button.
resourceId: resourceId ?? undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined, from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined, to: dateTo ? endOfDay(dateTo) : undefined,
}), }),
[pagination, type, method, outcome, search, dateFrom, dateTo], [pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo],
); );
const logsQuery = useQuery({ const logsQuery = useQuery({
@@ -88,12 +120,17 @@ const AuditLogsPage = () => {
queryFn: () => auditLogsService.types(), queryFn: () => auditLogsService.types(),
}); });
const actionsQuery = useQuery({
queryKey: ["audit-logs", "actions"],
queryFn: () => auditLogsService.actions(),
});
const rows = logsQuery.data?.items ?? []; const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0; const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0; const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean( const hasFilters = Boolean(
search || dateFrom || dateTo || type || method || outcome, search || dateFrom || dateTo || type || action || method || outcome,
); );
const resetFilters = () => { const resetFilters = () => {
@@ -101,6 +138,7 @@ const AuditLogsPage = () => {
setDateFrom(null); setDateFrom(null);
setDateTo(null); setDateTo(null);
setType(null); setType(null);
setAction(null);
setMethod(null); setMethod(null);
setOutcome(null); setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -135,7 +173,7 @@ const AuditLogsPage = () => {
<ListControls <ListControls
search={search} search={search}
onSearchChange={onFilterChange(setSearch)} onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Filter by record id…" searchPlaceholder="Booking / schedule / train number, staff name, action…"
dateFrom={dateFrom} dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)} onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo} dateTo={dateTo}
@@ -154,6 +192,16 @@ const AuditLogsPage = () => {
searchable searchable
w={200} w={200}
/> />
<Select
label="Action"
placeholder="All actions"
data={actionsQuery.data ?? []}
value={action}
onChange={onFilterChange(setAction)}
clearable
searchable
w={260}
/>
<Select <Select
label="Method" label="Method"
placeholder="All methods" placeholder="All methods"
@@ -193,10 +241,12 @@ const AuditLogsPage = () => {
<Table.Tr> <Table.Tr>
<Table.Th>Action</Table.Th> <Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th> <Table.Th>Entity</Table.Th>
<Table.Th>Reference</Table.Th>
<Table.Th>Method</Table.Th> <Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th> <Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th> <Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th> <Table.Th>When</Table.Th>
<Table.Th />
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -214,6 +264,11 @@ const AuditLogsPage = () => {
<Table.Td> <Table.Td>
<Badge variant="light">{log.type}</Badge> <Badge variant="light">{log.type}</Badge>
</Table.Td> </Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">
{log.reference || "—"}
</Text>
</Table.Td>
<Table.Td> <Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light"> <Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method} {log.method}
@@ -250,6 +305,21 @@ const AuditLogsPage = () => {
<Table.Td> <Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text> <Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td> </Table.Td>
<Table.Td>
{entityRoute(log) ? (
<Button
size="compact-xs"
variant="light"
onClick={(event) => {
// The row itself opens the detail modal.
event.stopPropagation();
navigate(entityRoute(log)!);
}}
>
Go
</Button>
) : null}
</Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>
@@ -277,6 +347,7 @@ const AuditLogsPage = () => {
<Stack gap="sm"> <Stack gap="sm">
<DetailRow label="Action" value={selected.title} /> <DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} /> <DetailRow label="Entity" value={selected.type} />
<DetailRow label="Reference" value={selected.reference || null} />
<DetailRow label="Record id" value={selected.resourceId} /> <DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} /> <DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} /> <DetailRow label="URL" value={selected.url} />

View File

@@ -74,6 +74,23 @@ interface WagonCancellation {
}; };
} }
interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/** Editable rebook unit — prefilled from the cancelled snapshot. */ /** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft { interface RebookUnitDraft {
containerSize: string; containerSize: string;
@@ -147,10 +164,12 @@ export default function WagonCancellationsPage() {
); );
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null); const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const [rebookDate, setRebookDate] = useState<Date | null>(null); const [rebookDate, setRebookDate] = useState<Date | null>(null);
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]); const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
const openRebook = (r: WagonCancellation) => { const openRebook = (r: WagonCancellation) => {
setRebooking(r); setRebooking(r);
setRebookDate(null); setRebookDate(null);
setRebookPartnerId(null);
setRebookDrafts( setRebookDrafts(
(r.cancelledQuantities?.units ?? []).map((u) => ({ (r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize, containerSize: u.containerSize,
@@ -179,9 +198,30 @@ export default function WagonCancellationsPage() {
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, { api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
scheduledDate: toDayString(rebookDate!), scheduledDate: toDayString(rebookDate!),
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}), ...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
}), }),
}); });
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
// the odd partner booking riding the chosen day. It ships once that partner pays.
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
const rebookPartners = useQuery({
queryKey: [
"wagon-cancellations",
rebooking?.id,
"rebook-partners",
rebookDate ? toDayString(rebookDate) : null,
],
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(rebookDate!) } },
);
return res.data;
},
});
const resetPage = () => const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -301,11 +341,12 @@ export default function WagonCancellationsPage() {
const r = row.original; const r = row.original;
const showVoid = r.status === "FEE_PENDING" && canVoid; const showVoid = r.status === "FEE_PENDING" && canVoid;
// Customs credits are GL's to rebook; non-customs ones the customer // Customs credits are GL's to rebook; non-customs ones the customer
// rebooks from the portal. // rebooks from the portal — EXCEPT odd-20ft credits: those must be
// re-paired with a partner booking, which only GL can pick.
const showRebook = const showRebook =
r.status === "CREDIT_AVAILABLE" && r.status === "CREDIT_AVAILABLE" &&
canRebook && canRebook &&
Boolean(r.booking?.customsClearingEnabled) && (Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
Number(r.creditAmount) > 0; Number(r.creditAmount) > 0;
if (!showVoid && !showRebook) return null; if (!showVoid && !showRebook) return null;
return ( return (
@@ -495,9 +536,43 @@ export default function WagonCancellationsPage() {
label="Shipment day" label="Shipment day"
placeholder="Pick the day" placeholder="Pick the day"
value={rebookDate} value={rebookDate}
onChange={(v) => setRebookDate(v ? new Date(v) : null)} onChange={(v) => {
setRebookDate(v ? new Date(v) : null);
setRebookPartnerId(null);
}}
radius="md" radius="md"
/> />
{rebookNeedsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
placeholder={
!rebookDate
? "Pick the day first"
: rebookPartners.isLoading
? "Loading…"
: "Pick the partner booking"
}
data={(rebookPartners.data ?? []).map((c) => ({
value: c.id,
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={rebookPartnerId}
onChange={setRebookPartnerId}
disabled={!rebookDate}
searchable
radius="md"
/>
)}
{rebookNeedsPartner &&
rebookDate &&
!rebookPartners.isLoading &&
(rebookPartners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait
for a partner booking.
</Text>
)}
{rebookDrafts.length > 0 && ( {rebookDrafts.length > 0 && (
<Stack gap={6}> <Stack gap={6}>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -569,7 +644,9 @@ export default function WagonCancellationsPage() {
<Button <Button
color="green" color="green"
radius="md" radius="md"
disabled={!rebookDate} disabled={
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
}
loading={rebook.isPending} loading={rebook.isPending}
onClick={async () => { onClick={async () => {
try { try {

View File

@@ -133,8 +133,14 @@ export default function TrainScheduleV2DetailPage() {
// Actual departure — staff often dispatch on paper first and record it later, // Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens). // so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null); const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Loading is manual: dispatch decides the fate of every unloaded origin
// boarder — checked = loaded and departs, unchecked = left behind (wagon
// freed, booking back to the pool). Default unchecked; government bookings
// cannot be removed from a train so they are forced on.
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
const openDispatchConfirm = () => { const openDispatchConfirm = () => {
setDispatchAt(new Date()); setDispatchAt(new Date());
setDispatchLoadedIds(new Set());
setDispatchConfirmOpen(true); setDispatchConfirmOpen(true);
}; };
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null); const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
@@ -465,6 +471,25 @@ export default function TrainScheduleV2DetailPage() {
// per yard from the track page's log-pass flow. Everything below is advisory. // per yard from the track page's log-pass flow. Everything below is advisory.
const hasDispatchWarnings = const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0; unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
// (plus government, which is shown but forced-loaded).
const originYardId = schedule.originStation?.id;
const pendingOriginBoarders = dispatchBookings.filter(
(b) =>
Boolean(b.originYardId) &&
b.originYardId === originYardId &&
!b.loadedAt &&
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
(b.isGovernment
? b.status === "APPROVED" || b.status === "PAID"
: b.status === "PAID" ||
// Shipping-line bookings ride from accept on the credit ledger.
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
);
const dispatchLeftCount = pendingOriginBoarders.filter(
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
).length;
const finalizeStep = hasContainerStep ? 3 : 2; const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -516,7 +541,12 @@ export default function TrainScheduleV2DetailPage() {
try { try {
await dispatch.mutateAsync({ await dispatch.mutateAsync({
id: scheduleId, id: scheduleId,
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}, payload: {
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
loadedBookingIds: pendingOriginBoarders
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
.map((b) => b.id),
},
}); });
await openMarshallingDocument({ await openMarshallingDocument({
title: "Train dispatched", title: "Train dispatched",
@@ -1524,6 +1554,48 @@ export default function TrainScheduleV2DetailPage() {
radius="md" radius="md"
/> />
{pendingOriginBoarders.length > 0 ? (
<Stack gap={6}>
<Text size="sm" fw={700}>
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"}
tick what was loaded
</Text>
<Text size="xs" c="dimmed">
Unticked bookings are left behind: removed from this train, their
wagons freed, and the booking returned to the pool for a later
schedule. The customer is notified.
</Text>
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
{pendingOriginBoarders.map((b) => (
<Checkbox
key={b.id}
size="sm"
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
disabled={b.isGovernment}
onChange={(e) => {
const next = new Set(dispatchLoadedIds);
if (e.currentTarget.checked) next.add(b.id);
else next.delete(b.id);
setDispatchLoadedIds(next);
}}
label={
<Text size="sm" span>
{b.reference ?? b.id.slice(0, 8)} {b.customer ?? "Unknown customer"}
{b.isGovernment ? " (government — always rides)" : ""}
</Text>
}
/>
))}
</Stack>
{dispatchLeftCount > 0 ? (
<Text size="xs" c="orange.7" fw={600}>
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
be left behind and returned to the booking pool.
</Text>
) : null}
</Stack>
) : null}
{hasDispatchWarnings ? ( {hasDispatchWarnings ? (
<Alert <Alert
color="orange" color="orange"

View File

@@ -36,6 +36,8 @@ export interface AuditLog {
userName: string | null; userName: string | null;
userRole: string | null; userRole: string | null;
resourceId: string | null; resourceId: string | null;
/** Human identifier of the record (booking reference, train number); '' when unknown. */
reference: string;
/** Sanitized request body; files appear as `__file` descriptors. */ /** Sanitized request body; files appear as `__file` descriptors. */
request: Record<string, unknown> | null; request: Record<string, unknown> | null;
ipAddress: string | null; ipAddress: string | null;
@@ -52,6 +54,14 @@ export interface AuditLogQuery {
userId?: string; userId?: string;
method?: AuditMethod; method?: AuditMethod;
resourceId?: string; resourceId?: string;
/** Case-insensitive prefix match on the human identifier. */
reference?: string;
/** Staff name, substring match. */
userName?: string;
/** Action title, substring match. */
title?: string;
/** Free text across reference, record id, staff name and action title. */
q?: string;
/** Omit for "any outcome". */ /** Omit for "any outcome". */
isSuccess?: boolean; isSuccess?: boolean;
/** Inclusive ISO 8601 bounds. */ /** Inclusive ISO 8601 bounds. */
@@ -72,6 +82,10 @@ function toParams(query: AuditLogQuery): Record<string, string | number> {
if (query.userId) params.userId = query.userId; if (query.userId) params.userId = query.userId;
if (query.method) params.method = query.method; if (query.method) params.method = query.method;
if (query.resourceId) params.resourceId = query.resourceId; if (query.resourceId) params.resourceId = query.resourceId;
if (query.reference) params.reference = query.reference;
if (query.userName) params.userName = query.userName;
if (query.title) params.title = query.title;
if (query.q) params.q = query.q;
if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess); if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess);
if (query.from) params.from = query.from; if (query.from) params.from = query.from;
if (query.to) params.to = query.to; if (query.to) params.to = query.to;
@@ -94,4 +108,10 @@ export const auditLogsService = {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`); const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
return unwrap(response.data); return unwrap(response.data);
}, },
/** Distinct action titles present, for the action filter dropdown. */
actions: async (): Promise<string[]> => {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/actions`);
return unwrap(response.data);
},
}; };

View File

@@ -773,6 +773,8 @@ export interface TrainScheduleDetail {
loadingStatus?: "LOADED" | "UNLOADED"; loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean; wagonAssigned?: boolean;
isGovernment?: boolean; isGovernment?: boolean;
/** Shipping-line bookings never prepay — FULLY_EXECUTED is boardable. */
shippingLineCompanyId?: string | null;
}>; }>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */ /** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>; stops?: Array<{ yardId: string; label: string }>;
@@ -944,6 +946,11 @@ export interface UpdateCheckpointPayload extends CheckpointHandlingTimes {
export interface DispatchSchedulePayload { export interface DispatchSchedulePayload {
/** Actual departure; defaults to now. Past OK, future rejected. */ /** Actual departure; defaults to now. Past OK, future rejected. */
actualDepartureAt?: string; actualDepartureAt?: string;
/**
* Origin-yard bookings staff confirmed loaded; every other unloaded origin
* boarder is unassigned back to the pool. Omit to auto-load all (legacy).
*/
loadedBookingIds?: string[];
} }
export interface TrainScheduleFilters { export interface TrainScheduleFilters {

View File

@@ -209,6 +209,14 @@ export function WagonCancellationCard({
// Non-customs: container number / seal / VGM may change at rebook. Customs // Non-customs: container number / seal / VGM may change at rebook. Customs
// (Path B) credits are rebooked by GL from the backoffice instead. // (Path B) credits are rebooked by GL from the backoffice instead.
const isCustoms = Boolean(booking.customsClearingEnabled); const isCustoms = Boolean(booking.customsClearingEnabled);
// Odd-20ft credit: the rebooked booking shares a wagon again and only GL can
// pick the partner — GL rebooks it whatever the contract kind (server enforces).
const oddFt20Credit =
Object.entries(creditRow?.cancelledQuantities?.bySize ?? {})
.filter(([sizeKey]) => parseInt(sizeKey, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[] | null>(null); const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[] | null>(null);
const snapshotUnits = creditRow?.cancelledQuantities?.units ?? []; const snapshotUnits = creditRow?.cancelledQuantities?.units ?? [];
const drafts = rebookDrafts ?? draftsFromSnapshot(snapshotUnits); const drafts = rebookDrafts ?? draftsFromSnapshot(snapshotUnits);
@@ -282,10 +290,11 @@ export function WagonCancellationCard({
is available. Pick a shipment day to rebook them as a new paid is available. Pick a shipment day to rebook them as a new paid
booking (no further payment needed). booking (no further payment needed).
</Alert> </Alert>
{isCustoms ? ( {isCustoms || oddFt20Credit ? (
<Text fz={13} c="#475569"> <Text fz={13} c="#475569">
This is a customs-cleared booking Global Logistics will rebook {isCustoms
the credit for you. ? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
</Text> </Text>
) : ( ) : (
<> <>

View File

@@ -50,6 +50,15 @@ export function RebookWagonsButton({
); );
const showEditor = Boolean(editableUnits) && drafts.length > 0; const showEditor = Boolean(editableUnits) && drafts.length > 0;
// An odd-20ft credit shares a wagon again on rebook, and only EDR staff can
// pick the partner booking — the portal cannot rebook it (server enforces).
const oddFt20 =
Object.entries(cancellation.cancelledQuantities?.bySize ?? {})
.filter(([sizeKey]) => parseInt(sizeKey, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
const rebook = useMutation({ const rebook = useMutation({
mutationFn: () => mutationFn: () =>
bookingsService.rebookWagonCancellation(cancellation.id, { bookingsService.rebookWagonCancellation(cancellation.id, {
@@ -67,6 +76,16 @@ export function RebookWagonsButton({
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")), toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
}); });
if (oddFt20) {
return (
<Text size="sm" c="dimmed">
Your credit includes an odd 20ft container that must share a wagon with
another booking Global Logistics will rebook it for you and pair the
wagon. Please contact EDR staff.
</Text>
);
}
return ( return (
<> <>
<Button <Button

View File

@@ -37,6 +37,7 @@ import {
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards"; import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service";
@ApiTags("Booking") @ApiTags("Booking")
@Controller("bookings") @Controller("bookings")
@@ -45,6 +46,7 @@ export class BookingsController {
constructor( constructor(
private service: BookingsService, private service: BookingsService,
private guestService: GuestBookingService, private guestService: GuestBookingService,
private seatsService: SeatsService,
) {} ) {}
@Get("my") @Get("my")
@@ -359,6 +361,37 @@ export class BookingsController {
return this.guestService.createGuestBooking(dto, req); return this.guestService.createGuestBooking(dto, req);
} }
@Post("group")
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY.
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
})
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
async createGroup(@Body() dto: CreateGuestBookingDto) {
try {
return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true });
} catch (err) {
try {
await this.seatsService.releaseHold(dto.holdId);
} catch (releaseErr) {
// Best-effort — the hold may already be gone (e.g. it expired mid-request). The
// original booking-creation error is what the caller actually needs to see.
}
throw err;
}
}
@Post("reservations/:seatId/issue") @Post("reservations/:seatId/issue")
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate) @PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")

View File

@@ -168,6 +168,15 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' }) @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
@IsOptional() @IsNumber() reviewedTotalMinor?: number; @IsOptional() @IsNumber() reviewedTotalMinor?: number;
@ApiPropertyOptional({
description:
'Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB, nationality). ' +
'For staff-entered/bulk-uploaded rosters (e.g. group bookings) where there is no live Fayda identity ' +
'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' +
'(overwriting the real name) or, once configured, would reject the whole booking on a non-match.',
})
@IsOptional() @IsBoolean() skipIdentityVerification?: boolean;
} }
export class SavedPassengerProfileDto { export class SavedPassengerProfileDto {

View File

@@ -196,7 +196,7 @@ export class GuestBookingService {
passenger.idDocumentType === IdDocumentType.NATIONAL_ID; passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) { if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) { if (!verification.verified) {
throw new BadRequestException( throw new BadRequestException(

View File

@@ -21,7 +21,7 @@ import {
ApiBody, ApiBody,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { SeatsService } from "./seats.service"; import { SeatsService } from "./seats.service";
import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto"; import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
@@ -186,6 +186,30 @@ This makes it clear which segment of the route each seat is held for, enabling s
return this.service.holdSeats(dto); return this.service.holdSeats(dto);
} }
@Post("auto-assign-hold")
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
})
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
@ApiResponse({ status: 409, description: "Not enough seats available in the requested class" })
autoAssignHold(@Body() dto: AutoAssignHoldDto) {
const passengerCount = dto.adultCount + (dto.childCount ?? 0);
return this.service.autoAssignAndHold(
dto.scheduleId,
dto.originStationId,
dto.destinationStationId,
dto.seatClassName,
passengerCount,
);
}
@Delete("hold/:holdId") @Delete("hold/:holdId")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator'; import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@@ -49,6 +49,26 @@ export class HoldSeatsDto {
passengers: PassengerSeatDto[]; passengers: PassengerSeatDto[];
} }
export class AutoAssignHoldDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name to auto-assign from — must match a class returned by POST /search for this schedule.' })
@IsString() seatClassName: string;
@ApiProperty({ example: 4, minimum: 1, description: 'Number of adult passengers to assign seats for.' })
@IsInt() @Min(0) adultCount: number;
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
@IsOptional() @IsInt() @Min(0) childCount?: number;
}
export class ReleaseHoldDto { export class ReleaseHoldDto {
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' }) @ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
@IsString() holdId: string; @IsString() holdId: string;

View File

@@ -18,6 +18,10 @@ describe('SeatsService - Auto Assign', () => {
findMany: jest.fn(), findMany: jest.fn(),
updateMany: jest.fn(), updateMany: jest.fn(),
}, },
seatClass: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
tripStopTime: { tripStopTime: {
findMany: jest.fn(), findMany: jest.fn(),
}, },
@@ -72,6 +76,14 @@ describe('SeatsService - Auto Assign', () => {
]); ]);
mockPrisma.seatBlock.findMany.mockResolvedValue([]); mockPrisma.seatBlock.findMany.mockResolvedValue([]);
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map()); mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-1',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Hard Seat Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: null },
]);
}); });
describe('assertNoRouteSeatConflict', () => { describe('assertNoRouteSeatConflict', () => {
@@ -114,25 +126,27 @@ describe('SeatsService - Auto Assign', () => {
}); });
describe('autoAssignSeats', () => { describe('autoAssignSeats', () => {
it('should assign contiguous seats in same row', async () => { it('should assign seats in ascending seat-number order (not row/insertion order)', async () => {
// Deliberately out of order and non-contiguous-by-row to prove the sort is driven by
// seatNumber, not by the order seats came back from the query or their row grouping.
const mockSeats = [ const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' }, { id: 'seat-3', seatNumber: '3', coachId: 'coach-1', row: 2, col: 'A' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' }, { id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' }, { id: 'seat-2', seatNumber: '2', coachId: 'coach-1', row: 1, col: 'B' },
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' }, { id: 'seat-10', seatNumber: '10', coachId: 'coach-1', row: 3, col: 'A' },
]; ];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats); mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR'); const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2); // Numeric order (1, 2) — a lexicographic sort would have put '10' before '2'.
expect(result).toEqual(['seat-1', 'seat-2']); expect(result).toEqual(['seat-1', 'seat-2']);
}); });
it('should throw error if not enough seats available', async () => { it('should throw error if not enough seats available', async () => {
mockPrisma.seat.findMany.mockResolvedValue([ mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' }, { id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
]); ]);
await expect( await expect(
@@ -140,22 +154,9 @@ describe('SeatsService - Auto Assign', () => {
).rejects.toThrow(ConflictException); ).rejects.toThrow(ConflictException);
}); });
it('should respect eligibility filter', async () => {
const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
expect(result).toHaveLength(2);
});
it('should assign single seat', async () => { it('should assign single seat', async () => {
const mockSeats = [ const mockSeats = [
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' }, { id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
]; ];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats); mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
@@ -164,5 +165,75 @@ describe('SeatsService - Auto Assign', () => {
expect(result).toEqual(['seat-1']); expect(result).toEqual(['seat-1']);
}); });
it('should fill Lower, then Middle, then Upper — a fixed physical order, not fare order', async () => {
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-hbc',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Hard Berth Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: 'UPPER' },
{ bedPosition: 'MIDDLE' },
{ bedPosition: 'LOWER' },
]);
// Upper is the cheapest tier in the seed data (4000 vs 5500 Middle vs 6000 Lower) — this
// deliberately picks seats so a fare-order algorithm and a lower-first algorithm disagree.
const mockSeats = [
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
{ id: 'upper-2', seatNumber: '17', coachId: 'coach-1', row: 4, col: 'B', bedPosition: 'UPPER' },
{ id: 'middle-1', seatNumber: '11', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'MIDDLE' },
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
{ id: 'lower-2', seatNumber: '7', coachId: 'coach-1', row: 2, col: 'B', bedPosition: 'LOWER' },
];
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
const result = await service.autoAssignSeats('trip-1', 3, 'Economy Bed Upper (Intl)');
// Both Lower seats first, then spill into Middle — Upper is untouched even though it's cheaper.
expect(result).toEqual(['lower-1', 'lower-2', 'middle-1']);
});
it('should count all fare tiers toward availability, not just one tier', async () => {
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-hbc',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Hard Berth Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: 'UPPER' },
{ bedPosition: 'LOWER' },
]);
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
]);
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
expect(result).toHaveLength(2);
});
it('should match bed-tier seats regardless of case (SeatClass.bedPosition is seeded uppercase, Seat.bedPosition is stored lowercase in production data)', async () => {
mockPrisma.seatClass.findFirst.mockResolvedValue({
coachTypeId: 'coach-type-sbc',
nationalityType: 'INTERNATIONAL',
coachType: { name: 'Soft Berth Coach' },
});
mockPrisma.seatClass.findMany.mockResolvedValue([
{ bedPosition: 'UPPER' },
{ bedPosition: 'LOWER' },
]);
mockPrisma.seat.findMany.mockResolvedValue([
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'upper' },
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'lower' },
]);
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
expect(result).toHaveLength(2);
// Lower fills before Upper regardless of case.
expect(result).toEqual(['lower-1', 'upper-1']);
});
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto'; import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
import { ActingUser } from '../../common/acting-user'; import { ActingUser } from '../../common/acting-user';
@@ -824,14 +825,56 @@ export class SeatsService {
}); });
if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule) throw new NotFoundException('Schedule not found');
const seats = await this.prisma.seat.findMany({ // Resolve the requested class to its actual SeatClass row, then pool seats across every
where: { // fare tier (bed position) that shares its coachTypeId + nationalityType. "Economy"/"VIP"
coach: { assignments: { some: { scheduleId } } }, // are coach categories, not one physical seat pool — Upper/Middle/Lower berths are
seatNumber: { not: '' }, // genuinely different seats priced differently — but the whole group is billed one uniform
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }], // rate (the cheapest tier, which is what callers pass as seatClassName; see
}, // bookings.controller's group endpoint). A coach type with no bed split (e.g. Economy
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], // Regular) has exactly one tier, so this collapses to plain seat-number order for it.
const seatClass = await this.prisma.seatClass.findFirst({
where: { name: seatClassName },
select: { coachTypeId: true, nationalityType: true, coachType: { select: { name: true } } },
}); });
if (!seatClass) throw new NotFoundException(`Seat class "${seatClassName}" not found`);
const siblingClasses = await this.prisma.seatClass.findMany({
where: { coachTypeId: seatClass.coachTypeId, nationalityType: seatClass.nationalityType },
select: { bedPosition: true },
});
// SeatClass.bedPosition is seeded uppercase ('UPPER'), but Seat.bedPosition is stored
// lowercase ('upper') — normalize both sides or every bed-tier seat silently fails to match.
const validBedPositions = new Set(siblingClasses.map((sc) => (sc.bedPosition ?? '').toLowerCase()));
// Fixed physical fill order — lower berths first, then middle, then upper — not fare-driven.
const BED_POSITION_ORDER: Record<string, number> = { lower: 0, middle: 1, upper: 2 };
const allSeatsOnSchedule = await this.prisma.seat.findMany({
where: {
coach: {
coachTypeId: seatClass.coachTypeId,
assignments: { some: { scheduleId } },
},
seatNumber: { not: '' },
// Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this
// method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at
// the query level regardless of an `as any` cast, so that clause would throw at
// runtime the moment this method was ever actually called. Maintenance-blocked seats
// are still excluded below via the schedule-scoped SeatBlock check.
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }],
},
orderBy: [{ coach: { number: 'asc' } }],
});
// Lower → Middle → Upper, then ascending seat number within a tier (seatNumber is a string
// column, so DB/lexicographic ordering would sort "10" before "2" — compare numerically here).
const seats = allSeatsOnSchedule
.filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase()))
.sort((a, b) => {
const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0)
- (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0);
if (tierDiff !== 0) return tierDiff;
return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10);
});
const allSeatIds = seats.map(s => s.id); const allSeatIds = seats.map(s => s.id);
const stopTimes = await this.prisma.tripStopTime.findMany({ const stopTimes = await this.prisma.tripStopTime.findMany({
@@ -856,30 +899,42 @@ export class SeatsService {
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id)); const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
if (availableSeats.length < count) { if (availableSeats.length < count) {
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`); throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`);
} }
const assigned = this.findContiguousSeats(availableSeats, count); // availableSeats is already ordered lower→middle→upper, ascending seat number within a
return assigned.map((s) => s.id); // tier — take the first `count` in that order, spilling into the next tier once one runs out.
return availableSeats.slice(0, count).map((s) => s.id);
} }
private findContiguousSeats(seats: any[], count: number): any[] { /**
if (count === 1) return [seats[0]]; * Auto-assigns `count` seats of `seatClassName` and immediately holds them in one request,
* for callers (like bulk/group booking) that must never show an assignment the caller could
const grouped = new Map<string, any[]>(); * lose to a race before confirming it. Reuses `holdSeats` as-is — a single hold already
for (const seat of seats) { * supports many seats/passengers in one row (see `SeatHold.seatIds: String[]`), so this is
const key = `${seat.coachId}-${seat.row}`; * pure orchestration, not a new hold mechanism.
if (!grouped.has(key)) grouped.set(key, []); */
grouped.get(key)!.push(seat); async autoAssignAndHold(
} scheduleId: string,
originStationId: string,
for (const rowSeats of grouped.values()) { destinationStationId: string,
if (rowSeats.length >= count) { seatClassName: string,
return rowSeats.slice(0, count); passengerCount: number,
} ) {
} const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
return seats.slice(0, count); // "group-1", "group-2"... would collide with any other still-active group-booking hold on
// the same schedule (e.g. an abandoned/retried attempt, or two staff members booking the
// same train within the hold TTL), tripping holdSeats' "passenger already holds a seat on
// this journey leg" conflict check for two entirely unrelated bookings.
const attemptId = randomUUID();
const passengers = seatIds.map((seatId, i) => ({ passengerId: `group-${attemptId}-${i + 1}`, seatId }));
return this.holdSeats({
scheduleId,
originStationId,
destinationStationId,
passengers,
} as HoldSeatsDto);
} }
async exportSeatsCSV(scheduleId: string): Promise<string> { async exportSeatsCSV(scheduleId: string): Promise<string> {

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ import {
Activity, Activity,
Smartphone, Smartphone,
Layers, Layers,
UsersRound,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -63,6 +64,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Operations', title: 'Operations',
items: [ items: [
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view }, { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
{ name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage },
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage }, { name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage },

View File

@@ -0,0 +1,219 @@
import { apiClient } from '@/lib/api-client';
// ── Search (POST /search) ──────────────────────────────────────────────────
export interface SearchTripsRequest {
originStationId: string;
destinationStationId: string;
date: string;
adultCount: number;
childCount?: number;
journeyType: 'ONE_WAY';
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
nationality?: string;
}
export interface ScheduleClassOption {
name: string;
baseFareMinor: number;
displayCurrency: string;
displayAmountMinor: number;
available: number;
}
export interface ScheduleCoachType {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: ScheduleClassOption[];
}
export interface ScheduleResult {
type: 'DIRECT';
scheduleId: string;
trainNumber: string;
trainName: string;
origin: { id: string; code: string; name: string; city: string; sequence: number };
destination: { id: string; code: string; name: string; city: string; sequence: number };
departureAt: string;
arrivalAt: string;
durationMinutes: number;
status: string;
hasAvailability: boolean;
displayCurrency: string;
coachTypes: ScheduleCoachType[];
}
export type SearchEmptyReasonCode =
| 'NO_ROUTE'
| 'NO_SCHEDULE_ON_DATE'
| 'CANCELLED'
| 'PACKAGE_ONLY'
| 'CHECKIN_CLOSED'
| 'FULLY_BOOKED';
/** Structured, not a string — always render via a code→message lookup, never directly. */
export interface SearchEmptyReason {
code: SearchEmptyReasonCode;
originStationName: string;
destinationStationName: string;
}
export interface SearchTripsResponse {
journeyType: string;
outbound: ScheduleResult[];
requestedDate: string;
outboundReason?: SearchEmptyReason;
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
alternativeOutbound?: ScheduleResult[];
}
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
export interface SeatClassOption {
id: string;
name: string;
}
// ── Auto-assign + hold (POST /seats/auto-assign-hold) ─────────────────────
export interface AutoAssignHoldRequest {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatClassName: string;
adultCount: number;
childCount?: number;
}
export interface HeldPassengerSeat {
passengerId: string;
seat: {
id: string;
label?: string;
seatNumber?: string;
coach?: string;
row?: number;
col?: string;
};
}
export interface AutoAssignHoldResponse {
holdId: string;
expiresAt: string;
ttlSeconds: number;
schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null;
passengers: HeldPassengerSeat[];
}
// ── Group booking creation (POST /bookings/group) ──────────────────────────
export interface GroupBookingPassengerInput {
seatId: string;
passengerName: string;
dateOfBirth: string;
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
idDocumentNumber?: string;
passportNumber?: string;
passportCountry?: string;
nationality?: string;
phone?: string;
email?: string;
}
export interface CreateGroupBookingRequest {
scheduleId: string;
holdId: string;
originStationId: string;
destinationStationId: string;
seatClassId: string;
bookingType: 'ONE_WAY';
passengers: GroupBookingPassengerInput[];
}
export interface GroupBookingSeat {
seatId: string;
passengerName: string;
passengerCategory: 'ADULT' | 'CHILD';
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
}
export interface CreateGroupBookingResponse {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency: string;
adultCount: number;
childCount: number;
seats: GroupBookingSeat[];
schedule: {
departureAt: string;
arrivalAt: string;
train: { number: string; name: string };
originStation: { name: string };
destinationStation: { name: string };
};
}
// ── Payment (GET /payments/methods, POST /payments/initiate) ───────────────
export type PaymentMethodType =
| 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL';
export interface SupportedPaymentMethod {
id: string;
type: PaymentMethodType;
displayName: string;
region: string;
currency: string;
enabled: boolean;
}
export interface InitiatePaymentRequest {
bookingId: string;
method: PaymentMethodType;
paymentMethodId?: string;
platform?: 'web' | 'mobile' | 'inapp';
}
export interface PaymentClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE';
url?: string;
/** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */
billReference?: string;
instructions?: string;
expiresAt?: string;
message?: string;
payerAccountMasked?: string;
}
export interface InitiatePaymentResponse {
intentId: string;
status: string;
clientAction?: PaymentClientAction;
merchantOrderId?: string;
failureCode?: string;
failureMessage?: string;
sessionExpiresAt?: string;
paymentDeadline?: string;
}
export const groupBookingApi = {
searchTrips: (dto: SearchTripsRequest) =>
apiClient.post<SearchTripsResponse>('/search', dto),
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
autoAssignHold: (dto: AutoAssignHoldRequest) =>
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
createGroupBooking: (dto: CreateGroupBookingRequest) =>
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
initiatePayment: (dto: InitiatePaymentRequest) =>
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
};

View File

@@ -0,0 +1,149 @@
import ExcelJS from 'exceljs';
// Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a
// small local copy rather than a shared import since these are two unrelated export domains.
const BRAND = 'FF14714C';
const BRAND_TINT = 'FFEAF5EF';
const INK = 'FF1F2937';
const MUTED = 'FF6B7280';
const BORDER = 'FFE2E5E1';
const WHITE = 'FFFFFFFF';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: BORDER } },
left: { style: 'thin', color: { argb: BORDER } },
bottom: { style: 'thin', color: { argb: BORDER } },
right: { style: 'thin', color: { argb: BORDER } },
};
/** Column order is the contract — passenger-excel.ts reads by this same header order. */
export const PASSENGER_TEMPLATE_COLUMNS = [
'Full Name',
'Date of Birth (YYYY-MM-DD)',
'Passenger Type',
'ID Document Type',
'ID Document Number',
'Passport Number',
'Passport Country',
'Nationality',
'Phone',
'Email',
] as const;
const REQUIRED_ROW = 200;
export interface PassengerTemplateInput {
trainNumber: string;
origin: string;
destination: string;
travelDate: string;
seatClassName: string;
adultCount: number;
childCount: number;
}
export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise<Blob> {
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
wb.created = new Date();
const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] });
ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 }));
// ── Title + trip context banner ──────────────────────────────────────────
ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length);
const title = ws.getCell(1, 1);
title.value = 'EDR Group Booking — Passenger Template';
title.font = { bold: true, size: 16, color: { argb: WHITE } };
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(1).height = 30;
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill;
ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length);
const subtitle = ws.getCell(2, 1);
subtitle.value = `Train ${input.trainNumber} · ${input.origin}${input.destination} · ${input.travelDate} · ${input.seatClassName}`;
subtitle.font = { size: 11, color: { argb: INK } };
subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(2).height = 20;
ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length);
const requirement = ws.getCell(3, 1);
const total = input.adultCount + input.childCount;
requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`;
requirement.font = { italic: true, size: 10, color: { argb: MUTED } };
requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(3).height = 18;
ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length);
const instructions = ws.getCell(4, 1);
instructions.value =
'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' +
'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.';
instructions.font = { size: 9, color: { argb: MUTED } };
instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true };
ws.getRow(4).height = 28;
// ── Header row ────────────────────────────────────────────────────────────
const headerRow = ws.getRow(5);
const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type
PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = requiredCols.has(i) ? `${h} *` : h;
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true };
cell.border = THIN_BORDER;
});
headerRow.height = 30;
// ── One filled example row so the format is obvious at a glance ──────────
const example = ws.getRow(6);
const exampleValues = [
'Abebe Kebede',
'1990-05-15',
'Adult',
'NATIONAL_ID',
'ET123456789',
'',
'',
'Ethiopian',
'+251911234567',
'abebe@example.com',
];
exampleValues.forEach((v, i) => {
const cell = example.getCell(i + 1);
cell.value = v;
cell.font = { italic: true, color: { argb: MUTED } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
cell.border = THIN_BORDER;
});
// ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ──
// exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API.
for (let r = 7; r <= REQUIRED_ROW; r++) {
const row = ws.getRow(r);
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) {
row.getCell(c).border = THIN_BORDER;
}
row.getCell(3).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"Adult,Child"'],
showErrorMessage: true,
errorTitle: 'Invalid Passenger Type',
error: 'Choose Adult or Child.',
};
row.getCell(4).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'],
showErrorMessage: true,
errorTitle: 'Invalid ID Document Type',
error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.',
};
}
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}

View File

@@ -0,0 +1,229 @@
import ExcelJS from 'exceljs';
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
/**
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
* row that will actually be priced at a different fare tier than the one quoted at search time
* is caught here instead of silently mispricing the group later.
*/
function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' {
const natUpper = nationality.trim().toUpperCase();
const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID';
let resolved = nationality;
if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian';
else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian';
const resolvedUpper = resolved.trim().toUpperCase();
return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
}
export interface ParsedPassengerRow {
/** 1-based row number in the sheet, for error messages ("row 8"). */
rowNumber: number;
fullName: string;
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
passengerType: 'Adult' | 'Child' | '';
idDocumentType: string;
idDocumentNumber: string;
passportNumber: string;
passportCountry: string;
nationality: string;
phone: string;
email: string;
errors: string[];
warnings: string[];
}
export interface ParsePassengerExcelResult {
rows: ParsedPassengerRow[];
/** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */
fileErrors: string[];
}
function cellText(row: ExcelJS.Row, colIndex: number): string {
if (colIndex < 1) return '';
const v = row.getCell(colIndex).value;
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString().split('T')[0];
if (typeof v === 'object') {
const anyV = v as any;
if (typeof anyV.text === 'string') return anyV.text.trim();
if (anyV.result !== undefined) return String(anyV.result).trim();
if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim();
}
return String(v).trim();
}
/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */
function normalizeHeader(h: string): string {
return h.replace(/\s*\*\s*$/, '').trim();
}
export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise<ParsePassengerExcelResult> {
const buffer = await file.arrayBuffer();
const wb = new ExcelJS.Workbook();
try {
await wb.xlsx.load(buffer);
} catch {
return {
rows: [],
fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'],
};
}
const ws = wb.worksheets[0];
if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] };
// Locate the header row by scanning the first several rows for one starting with "Full Name" —
// the template puts it at row 5 (after the title/instruction banners), but scanning is more
// forgiving of an edited file than hardcoding a row number.
let headerRowIndex = -1;
let headers: string[] = [];
for (let r = 1; r <= 10; r++) {
const row = ws.getRow(r);
const values: string[] = [];
for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c)));
if (values.some((v) => v.toLowerCase().startsWith('full name'))) {
headerRowIndex = r;
headers = values;
break;
}
}
if (headerRowIndex === -1) {
return {
rows: [],
fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'],
};
}
const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1;
const idx = {
fullName: colFor('Full Name'),
dob: colFor('Date of Birth'),
type: colFor('Passenger Type'),
docType: colFor('ID Document Type'),
docNumber: colFor('ID Document Number'),
passportNumber: colFor('Passport Number'),
passportCountry: colFor('Passport Country'),
nationality: colFor('Nationality'),
phone: colFor('Phone'),
email: colFor('Email'),
};
if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) {
return {
rows: [],
fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'],
};
}
const rows: ParsedPassengerRow[] = [];
const lastRow = ws.actualRowCount || ws.rowCount;
for (let r = headerRowIndex + 1; r <= lastRow; r++) {
const row = ws.getRow(r);
const fullName = cellText(row, idx.fullName);
const dobRaw = cellText(row, idx.dob);
const typeRaw = cellText(row, idx.type);
const docTypeRaw = cellText(row, idx.docType).toUpperCase();
const docNumber = cellText(row, idx.docNumber);
const passportNumber = cellText(row, idx.passportNumber);
const passportCountry = cellText(row, idx.passportCountry);
const nationality = cellText(row, idx.nationality);
const phone = cellText(row, idx.phone);
const email = cellText(row, idx.email);
// Skip fully blank trailing rows (the template pre-formats borders down to row 200).
if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) {
continue;
}
const errors: string[] = [];
const warnings: string[] = [];
if (!fullName) errors.push('Full Name is required');
let dateOfBirth = '';
let ageYears: number | null = null;
if (!dobRaw) {
errors.push('Date of Birth is required');
} else {
const parsed = new Date(dobRaw);
if (isNaN(parsed.getTime())) {
errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`);
} else if (parsed.getTime() > Date.now()) {
errors.push('Date of Birth cannot be in the future');
} else {
dateOfBirth = parsed.toISOString().split('T')[0];
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
}
}
let passengerType: 'Adult' | 'Child' | '' = '';
const normalizedType = typeRaw.trim().toLowerCase();
if (normalizedType === 'adult') passengerType = 'Adult';
else if (normalizedType === 'child') passengerType = 'Child';
else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`);
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
// of this column — flag a mismatch so the uploader notices before it surprises them later.
if (passengerType && ageYears !== null) {
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
if (impliedType !== passengerType) {
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
}
}
if (!docTypeRaw) {
errors.push('ID Document Type is required');
} else if (!VALID_ID_TYPES.includes(docTypeRaw)) {
errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`);
}
if (docTypeRaw === 'PASSPORT' && !passportNumber) {
warnings.push('Passport Number is empty for a PASSPORT document type');
}
// The whole group is priced at one uniform fare tier (the one quoted at search time) — a
// row whose document type/nationality would actually resolve to the other tier will be
// priced wrong (over- or under-charged) with no per-passenger fare split to fix it.
if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) {
const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry);
if (rowTier !== quotedFareTier) {
warnings.push(
`This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`,
);
}
}
rows.push({
rowNumber: r,
fullName,
dateOfBirth,
passengerType,
idDocumentType: docTypeRaw,
idDocumentNumber: docNumber,
passportNumber,
passportCountry,
nationality,
phone,
email,
errors,
warnings,
});
}
if (rows.length === 0) {
return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] };
}
return { rows, fileErrors: [] };
}
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
return {
adults: rows.filter((r) => r.passengerType === 'Adult').length,
children: rows.filter((r) => r.passengerType === 'Child').length,
};
}