mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance booking and audit log functionalities
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
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.
|
||||
*
|
||||
* 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. */
|
||||
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/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/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-slip": ["Customer uploads duty/tax payment slip on booking", "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"],
|
||||
"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/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/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/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"],
|
||||
@@ -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"],
|
||||
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
// "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
|
||||
@@ -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/submit": ["Customer submit booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
// "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
||||
"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/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
// "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
|
||||
@@ -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"],
|
||||
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
|
||||
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
|
||||
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
// "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
|
||||
// Driver
|
||||
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
|
||||
@@ -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/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-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
|
||||
"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"],
|
||||
"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
|
||||
"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"],
|
||||
@@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
||||
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||
"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
|
||||
"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
|
||||
"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/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"],
|
||||
|
||||
// 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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
@@ -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/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
|
||||
@@ -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"],
|
||||
"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/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"],
|
||||
"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"],
|
||||
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
|
||||
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
// "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import type { AuditReferenceSource } from './audit-reference.registry';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
type?: string;
|
||||
@@ -12,6 +13,10 @@ export interface AuditLogQuery {
|
||||
method?: string;
|
||||
isSuccess?: boolean;
|
||||
resourceId?: string;
|
||||
reference?: string;
|
||||
userName?: string;
|
||||
title?: string;
|
||||
q?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
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
|
||||
* 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]> {
|
||||
const where: FindOptionsWhere<AuditLog> = {};
|
||||
const qb = this.auditLogRepository.createQueryBuilder('audit_log');
|
||||
|
||||
if (query.type) where.type = query.type;
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.method) where.method = query.method;
|
||||
if (query.resourceId) where.resourceId = query.resourceId;
|
||||
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess;
|
||||
if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type });
|
||||
if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId });
|
||||
if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method });
|
||||
if (query.resourceId) {
|
||||
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.
|
||||
if (query.from && query.to) where.createdAt = Between(query.from, query.to);
|
||||
else if (query.from) where.createdAt = MoreThanOrEqual(query.from);
|
||||
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
|
||||
if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from });
|
||||
if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to });
|
||||
|
||||
return this.auditLogRepository.findAndCount({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: query.skip,
|
||||
take: query.take,
|
||||
});
|
||||
return qb
|
||||
.orderBy('audit_log.created_at', 'DESC')
|
||||
.skip(query.skip)
|
||||
.take(query.take)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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[]> {
|
||||
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 { AuditLogRepository } from './audit-log.repository';
|
||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
import {
|
||||
AUDIT_REFERENCE_SOURCES,
|
||||
UUID_PATTERN,
|
||||
} from './audit-reference.registry';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
@@ -25,6 +29,7 @@ export class AuditService {
|
||||
*/
|
||||
async record(entry: Partial<AuditLog>): Promise<void> {
|
||||
try {
|
||||
entry.reference = await this.resolveReference(entry.type, entry.resourceId);
|
||||
await this.auditLogRepository.record(entry);
|
||||
} catch (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. */
|
||||
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
@@ -53,6 +86,10 @@ export class AuditService {
|
||||
userId: query.userId,
|
||||
method: query.method,
|
||||
resourceId: query.resourceId,
|
||||
reference: query.reference,
|
||||
userName: query.userName,
|
||||
title: query.title,
|
||||
q: query.q,
|
||||
isSuccess:
|
||||
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
|
||||
from,
|
||||
@@ -68,4 +105,9 @@ export class AuditService {
|
||||
async listTypes(): Promise<string[]> {
|
||||
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)
|
||||
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({
|
||||
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 })
|
||||
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
|
||||
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
|
||||
|
||||
@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
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,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -782,6 +783,26 @@ export class BookingWagonCancellationService {
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
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(
|
||||
source.contractId,
|
||||
createDto,
|
||||
@@ -814,12 +835,19 @@ export class BookingWagonCancellationService {
|
||||
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (partner) {
|
||||
// Consolidated rebook: never allocate the half-wagon booking alone. It
|
||||
// rides PAID and the batch engine settles the pair atomically once the
|
||||
// partner's own invoice is paid.
|
||||
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, {
|
||||
@@ -837,6 +865,142 @@ export class BookingWagonCancellationService {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
list(filter: WagonCancellationListFilter) {
|
||||
|
||||
@@ -725,6 +725,30 @@ export class BookingsController {
|
||||
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")
|
||||
@ApiOperation({
|
||||
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)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
|
||||
@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => 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 {
|
||||
|
||||
@@ -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 { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import {
|
||||
@@ -395,6 +396,10 @@ export class ContractBookingService {
|
||||
if (
|
||||
withContainers &&
|
||||
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(
|
||||
withContainers,
|
||||
))
|
||||
@@ -863,6 +868,12 @@ export class ContractBookingService {
|
||||
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;
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
@@ -1052,6 +1063,72 @@ export class ContractBookingService {
|
||||
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
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
|
||||
@@ -566,8 +566,9 @@ export class TrainSchedulingController {
|
||||
dispatchSchedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: DispatchScheduleDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto);
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("intercity/bookings")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
@@ -67,4 +69,20 @@ export class DispatchScheduleDto {
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -2360,16 +2360,22 @@ export class TrainSchedulingService {
|
||||
if (!schedule) {
|
||||
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);
|
||||
if (!link) {
|
||||
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new BadRequestException(
|
||||
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
|
||||
@@ -2437,6 +2443,21 @@ export class TrainSchedulingService {
|
||||
for (const slot of survivingSlots) {
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
@@ -2466,8 +2487,10 @@ export class TrainSchedulingService {
|
||||
|
||||
// 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
|
||||
// again for import/export).
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
// again for import/export). A dispatched train's window stays CLOSED.
|
||||
if (schedule.status !== 'DISPATCHED') {
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
}
|
||||
|
||||
await this.trainCompositionRemovalLogRepository.create({
|
||||
scheduleId,
|
||||
@@ -2832,14 +2855,35 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) {
|
||||
let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
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.
|
||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||
this.assertNotFuture(now, 'Departure time');
|
||||
@@ -3061,6 +3105,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) {
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
@@ -9798,6 +9870,9 @@ export class TrainSchedulingService {
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||
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
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
import {
|
||||
ConsolidationPartnerPanel,
|
||||
emptyPartnerLine,
|
||||
emptyPartnerUnit,
|
||||
} from "./gl-booking-form/ConsolidationPartnerPanel";
|
||||
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
|
||||
|
||||
@@ -250,6 +251,17 @@ export default function GlCreateBookingForm() {
|
||||
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).
|
||||
const { data: copyFromBooking } = useQuery({
|
||||
queryKey: ["rebook-copy-from", copyFromParam],
|
||||
@@ -328,6 +340,39 @@ export default function GlCreateBookingForm() {
|
||||
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
|
||||
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
|
||||
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 returnSeededRef = useRef(false);
|
||||
|
||||
@@ -490,6 +535,14 @@ export default function GlCreateBookingForm() {
|
||||
if (bookingRequest.contractRouteId)
|
||||
setContractRouteId(bookingRequest.contractRouteId);
|
||||
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]);
|
||||
|
||||
// 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;
|
||||
|
||||
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: new Date(scheduledDate).toISOString() }
|
||||
: {}),
|
||||
@@ -1663,6 +1722,12 @@ export default function GlCreateBookingForm() {
|
||||
label="Quantity *"
|
||||
min={0}
|
||||
value={line.quantity}
|
||||
disabled={requestContainersLocked}
|
||||
description={
|
||||
requestContainersLocked
|
||||
? "Requested by the customer — quantity cannot be changed."
|
||||
: undefined
|
||||
}
|
||||
error={
|
||||
showErrors
|
||||
? (lineErrors[lineIdx]?.quantity ??
|
||||
@@ -1924,6 +1989,7 @@ export default function GlCreateBookingForm() {
|
||||
showReefer={Boolean(contract.isReefer)}
|
||||
showErrors={showErrors}
|
||||
error={partnerError}
|
||||
lockQuantities={partnerLocked}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1946,6 +2012,12 @@ export default function GlCreateBookingForm() {
|
||||
placeholder="e.g. 1200"
|
||||
min={0}
|
||||
step={0.01}
|
||||
disabled={requestBulkLocked}
|
||||
description={
|
||||
requestBulkLocked
|
||||
? "Requested by the customer — quantity cannot be changed."
|
||||
: undefined
|
||||
}
|
||||
value={bulk.cargoWeightTons}
|
||||
error={
|
||||
showErrors && bulkUom === "PER_TON"
|
||||
@@ -2147,14 +2219,16 @@ export default function GlCreateBookingForm() {
|
||||
Billing currency
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
{isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
: "Shipments are invoiced in ETB."}
|
||||
{requestCurrencyLocked
|
||||
? "The customer chose the billing currency on the shipment request — it cannot be changed."
|
||||
: isImport
|
||||
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
|
||||
: "Shipments are invoiced in ETB."}
|
||||
</Text>
|
||||
<CurrencySelector
|
||||
value={isImport ? paymentCurrency : "ETB"}
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={!isImport}
|
||||
disabled={!isImport || requestCurrencyLocked}
|
||||
allowUsd={isImport}
|
||||
error={currencyError}
|
||||
/>
|
||||
|
||||
@@ -86,6 +86,11 @@ interface Props {
|
||||
/** Surface field errors only after the operator tried to continue. */
|
||||
showErrors: boolean;
|
||||
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({
|
||||
@@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({
|
||||
showReefer,
|
||||
showErrors,
|
||||
error,
|
||||
lockQuantities,
|
||||
}: Props) {
|
||||
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
|
||||
onLinesChange(
|
||||
@@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({
|
||||
label="Quantity *"
|
||||
min={0}
|
||||
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 })}
|
||||
// Sync off the typed value, not the captured `line` — that snapshot
|
||||
// still holds the pre-edit quantity and would write it back.
|
||||
|
||||
@@ -115,6 +115,7 @@ export function LogPassYardWorkModal({
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
// When the train was here — defaults to now, past allowed (recorded after the fact).
|
||||
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||
@@ -134,6 +135,10 @@ export function LogPassYardWorkModal({
|
||||
api.trainScheduling.recordCheckpoint.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 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;
|
||||
|
||||
return (
|
||||
@@ -355,30 +382,54 @@ export function LogPassYardWorkModal({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!row.loadedAt ? (
|
||||
<Tooltip
|
||||
label={
|
||||
!canLoad
|
||||
? "You don't have permission to load cargo"
|
||||
: !logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "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
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!canLoad
|
||||
? "You don't have permission to load cargo"
|
||||
: !logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
: "Confirm cargo loaded onto the train"
|
||||
}
|
||||
onClick={() => doLoad(row)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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>
|
||||
</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}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
@@ -42,6 +44,26 @@ const OUTCOME_OPTIONS = [
|
||||
{ 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. */
|
||||
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
|
||||
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 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
|
||||
// already-fetched array via useListControls), audit_logs is append-only and
|
||||
// grows without bound, so filtering and paging both happen in the API.
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState(searchParams.get("q") ?? "");
|
||||
const [dateFrom, setDateFrom] = 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 [outcome, setOutcome] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<AuditLog | null>(null);
|
||||
@@ -69,13 +98,16 @@ const AuditLogsPage = () => {
|
||||
type: type ?? undefined,
|
||||
method: (method as AuditMethod | null) ?? undefined,
|
||||
isSuccess: outcome === null ? undefined : outcome === "true",
|
||||
// The API filters by record id; the search box is the natural place to
|
||||
// paste one when tracing what happened to a specific contract/booking.
|
||||
resourceId: search.trim() || undefined,
|
||||
// Free-text: matches reference (booking/schedule/train number), record
|
||||
// id, staff name and action title server-side.
|
||||
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,
|
||||
to: dateTo ? endOfDay(dateTo) : undefined,
|
||||
}),
|
||||
[pagination, type, method, outcome, search, dateFrom, dateTo],
|
||||
[pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
const logsQuery = useQuery({
|
||||
@@ -88,12 +120,17 @@ const AuditLogsPage = () => {
|
||||
queryFn: () => auditLogsService.types(),
|
||||
});
|
||||
|
||||
const actionsQuery = useQuery({
|
||||
queryKey: ["audit-logs", "actions"],
|
||||
queryFn: () => auditLogsService.actions(),
|
||||
});
|
||||
|
||||
const rows = logsQuery.data?.items ?? [];
|
||||
const totalCount = logsQuery.data?.meta.total ?? 0;
|
||||
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
|
||||
|
||||
const hasFilters = Boolean(
|
||||
search || dateFrom || dateTo || type || method || outcome,
|
||||
search || dateFrom || dateTo || type || action || method || outcome,
|
||||
);
|
||||
|
||||
const resetFilters = () => {
|
||||
@@ -101,6 +138,7 @@ const AuditLogsPage = () => {
|
||||
setDateFrom(null);
|
||||
setDateTo(null);
|
||||
setType(null);
|
||||
setAction(null);
|
||||
setMethod(null);
|
||||
setOutcome(null);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -135,7 +173,7 @@ const AuditLogsPage = () => {
|
||||
<ListControls
|
||||
search={search}
|
||||
onSearchChange={onFilterChange(setSearch)}
|
||||
searchPlaceholder="Filter by record id…"
|
||||
searchPlaceholder="Booking / schedule / train number, staff name, action…"
|
||||
dateFrom={dateFrom}
|
||||
onDateFromChange={onFilterChange(setDateFrom)}
|
||||
dateTo={dateTo}
|
||||
@@ -154,6 +192,16 @@ const AuditLogsPage = () => {
|
||||
searchable
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Action"
|
||||
placeholder="All actions"
|
||||
data={actionsQuery.data ?? []}
|
||||
value={action}
|
||||
onChange={onFilterChange(setAction)}
|
||||
clearable
|
||||
searchable
|
||||
w={260}
|
||||
/>
|
||||
<Select
|
||||
label="Method"
|
||||
placeholder="All methods"
|
||||
@@ -193,10 +241,12 @@ const AuditLogsPage = () => {
|
||||
<Table.Tr>
|
||||
<Table.Th>Action</Table.Th>
|
||||
<Table.Th>Entity</Table.Th>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Method</Table.Th>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Outcome</Table.Th>
|
||||
<Table.Th>When</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -214,6 +264,11 @@ const AuditLogsPage = () => {
|
||||
<Table.Td>
|
||||
<Badge variant="light">{log.type}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" ff="monospace">
|
||||
{log.reference || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={METHOD_COLORS[log.method]} variant="light">
|
||||
{log.method}
|
||||
@@ -250,6 +305,21 @@ const AuditLogsPage = () => {
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
|
||||
</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.Tbody>
|
||||
@@ -277,6 +347,7 @@ const AuditLogsPage = () => {
|
||||
<Stack gap="sm">
|
||||
<DetailRow label="Action" value={selected.title} />
|
||||
<DetailRow label="Entity" value={selected.type} />
|
||||
<DetailRow label="Reference" value={selected.reference || null} />
|
||||
<DetailRow label="Record id" value={selected.resourceId} />
|
||||
<DetailRow label="Method" value={selected.method} />
|
||||
<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. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
@@ -147,10 +164,12 @@ export default function WagonCancellationsPage() {
|
||||
);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
const [rebookDate, setRebookDate] = useState<Date | null>(null);
|
||||
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
const openRebook = (r: WagonCancellation) => {
|
||||
setRebooking(r);
|
||||
setRebookDate(null);
|
||||
setRebookPartnerId(null);
|
||||
setRebookDrafts(
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
@@ -179,9 +198,30 @@ export default function WagonCancellationsPage() {
|
||||
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
|
||||
scheduledDate: toDayString(rebookDate!),
|
||||
...(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 = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
@@ -301,11 +341,12 @@ export default function WagonCancellationsPage() {
|
||||
const r = row.original;
|
||||
const showVoid = r.status === "FEE_PENDING" && canVoid;
|
||||
// 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 =
|
||||
r.status === "CREDIT_AVAILABLE" &&
|
||||
canRebook &&
|
||||
Boolean(r.booking?.customsClearingEnabled) &&
|
||||
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
|
||||
Number(r.creditAmount) > 0;
|
||||
if (!showVoid && !showRebook) return null;
|
||||
return (
|
||||
@@ -495,9 +536,43 @@ export default function WagonCancellationsPage() {
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={rebookDate}
|
||||
onChange={(v) => setRebookDate(v ? new Date(v) : null)}
|
||||
onChange={(v) => {
|
||||
setRebookDate(v ? new Date(v) : null);
|
||||
setRebookPartnerId(null);
|
||||
}}
|
||||
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 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -569,7 +644,9 @@ export default function WagonCancellationsPage() {
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={!rebookDate}
|
||||
disabled={
|
||||
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
|
||||
}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
@@ -133,8 +133,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
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 = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchLoadedIds(new Set());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
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.
|
||||
const hasDispatchWarnings =
|
||||
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 canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
@@ -516,7 +541,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
try {
|
||||
await dispatch.mutateAsync({
|
||||
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({
|
||||
title: "Train dispatched",
|
||||
@@ -1524,6 +1554,48 @@ export default function TrainScheduleV2DetailPage() {
|
||||
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 ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface AuditLog {
|
||||
userName: string | null;
|
||||
userRole: 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. */
|
||||
request: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
@@ -52,6 +54,14 @@ export interface AuditLogQuery {
|
||||
userId?: string;
|
||||
method?: AuditMethod;
|
||||
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". */
|
||||
isSuccess?: boolean;
|
||||
/** 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.method) params.method = query.method;
|
||||
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.from) params.from = query.from;
|
||||
if (query.to) params.to = query.to;
|
||||
@@ -94,4 +108,10 @@ export const auditLogsService = {
|
||||
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
|
||||
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";
|
||||
wagonAssigned?: boolean;
|
||||
isGovernment?: boolean;
|
||||
/** Shipping-line bookings never prepay — FULLY_EXECUTED is boardable. */
|
||||
shippingLineCompanyId?: string | null;
|
||||
}>;
|
||||
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||
stops?: Array<{ yardId: string; label: string }>;
|
||||
@@ -931,6 +933,11 @@ export interface UpdateCheckpointPayload {
|
||||
export interface DispatchSchedulePayload {
|
||||
/** Actual departure; defaults to now. Past OK, future rejected. */
|
||||
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 {
|
||||
|
||||
@@ -209,6 +209,14 @@ export function WagonCancellationCard({
|
||||
// Non-customs: container number / seal / VGM may change at rebook. Customs
|
||||
// (Path B) credits are rebooked by GL from the backoffice instead.
|
||||
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 snapshotUnits = creditRow?.cancelledQuantities?.units ?? [];
|
||||
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
|
||||
booking (no further payment needed).
|
||||
</Alert>
|
||||
{isCustoms ? (
|
||||
{isCustoms || oddFt20Credit ? (
|
||||
<Text fz={13} c="#475569">
|
||||
This is a customs-cleared booking — Global Logistics will rebook
|
||||
the credit for you.
|
||||
{isCustoms
|
||||
? "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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -50,6 +50,15 @@ export function RebookWagonsButton({
|
||||
);
|
||||
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({
|
||||
mutationFn: () =>
|
||||
bookingsService.rebookWagonCancellation(cancellation.id, {
|
||||
@@ -67,6 +76,16 @@ export function RebookWagonsButton({
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user