mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardLayout from '../dashboard/layout';
|
||||||
|
|
||||||
|
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
1133
apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx
Normal file
1133
apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
||||||
|
|||||||
219
apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
Normal file
219
apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts
Normal 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),
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user