Merge branch 'dev' into freight/nati-2

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

View File

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

View File

@@ -12,7 +12,7 @@
* humanized handler name where a route has none.
*
* 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';
@@ -113,4 +115,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

@@ -2370,16 +2370,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.',
@@ -2447,6 +2453,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;
}
@@ -2476,8 +2497,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,
@@ -2842,14 +2865,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');
@@ -3071,6 +3115,34 @@ export class TrainSchedulingService {
});
}
/**
* Origin boarders the dispatch dialog decides over: unloaded (no journey
* load, no workspace LOADED flag), boardable, non-government. Boardable is
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
* (their charge sits on the credit ledger) yet ride from accept.
*/
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
): Promise<string[]> {
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT b.id
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id
WHERE tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false
AND (b.status = 'PAID'
OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
[scheduleId, originYardId],
);
return rows.map((r) => r.id);
}
async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -9866,6 +9938,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.