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:
Marshal
2026-08-24 23:49:20 +00:00
parent 2a107e8ba3
commit d5a5085d6d
28 changed files with 1356 additions and 86 deletions

View File

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

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none.
*
* 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"],

View File

@@ -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}`);
}

View File

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

View File

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

View File

@@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types';
import { AuditLog } from './entities/audit-log.entity';
import { 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();
}
}

View File

@@ -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.',
})

View File

@@ -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 —

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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:

View File

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

View File

@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
@ValidateNested({ each: true })
@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 {

View File

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

View File

@@ -36,6 +36,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { 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.

View File

@@ -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")

View File

@@ -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[];
}

View File

@@ -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.

View File

@@ -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}
/>

View File

@@ -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.

View File

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

View File

@@ -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} />

View File

@@ -74,6 +74,23 @@ interface WagonCancellation {
};
}
interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/** Editable rebook unit — prefilled from the cancelled snapshot. */
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 {

View File

@@ -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"

View File

@@ -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);
},
};

View File

@@ -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 {

View File

@@ -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>
) : (
<>

View File

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