Merge pull request #1010 from Tria-plc/dev

merge
This commit is contained in:
Abubeker Yasin
2026-07-29 17:08:25 +03:00
committed by GitHub
203 changed files with 9350 additions and 4436 deletions

View File

@@ -2,6 +2,7 @@ import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
RequestMethod,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
@@ -31,6 +32,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -101,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
@Module({
imports: [
@@ -173,6 +176,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -240,6 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware";
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -328,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
consumer.apply(LoginAudienceMiddleware).forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
}
}

View File

@@ -2,6 +2,7 @@ import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
HttpExceptionFilter,
@@ -11,8 +12,25 @@ import {
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
*/
const JSON_BODY_LIMIT = '20mb';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
// is not a declared dependency of this app (it arrives under
// @nestjs/platform-express), so importing it directly resolved only through
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
// image, where `pnpm deploy --prod` installs declared dependencies only.
// This also RECONFIGURES the default parsers rather than racing them.
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
@@ -28,6 +46,9 @@ async function bootstrap() {
"Accept",
"Authorization",
"X-Requested-With",
// Which freight frontend is calling — /auth/login uses this to reject
// cross-audience credentials (EDRFREIGHT-415).
"X-Client-App",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
@@ -40,6 +61,13 @@ async function bootstrap() {
"x-delegator-position-id",
"x-current-project-id",
"x-current-position-id",
// Headers sent by the freight-backoffice OKR/objective-service client
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
// and "x-delegated-position-id" (delegated, not delegator).
"tenant-key",
"unit-id",
"x-delegated-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backoffice contract suspension (reversible freeze at any post-signature step)
* and customer-initiated contract cancellation.
*
* Only one new column is needed: the status to restore when the suspension is
* lifted. The reason and the actor already have a home — contract_review_notes
* rows with note_type SUSPENSION / SUSPENSION_LIFTED / CANCELLATION.
*/
export class AddContractSuspension3000000000000 implements MigrationInterface {
name = 'AddContractSuspension3000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`,
);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake on the SHIPMENT, not the contract.
*
* Clearance runs per booking now, so the ask GL Ethiopia raises before filing a
* customs declaration ("who handles this shipment in Djibouti?") and Djibouti's
* answer belong on the booking. The contract-cycle columns added by
* 2950000000000 stay for the legacy contract-level cycles.
*/
export class AddBookingTransitAssignee3010000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A contract's `created_at` is the DRAFT row's insert time, not when the
* customer actually submitted it for review — a DRAFT can sit edited for days
* first. `submitted_at` is stamped by ContractTransitionService.submit /
* confirmSubmit so the history UI can show a real submission time.
*/
export class AddContractSubmittedAt3020000000000 implements MigrationInterface {
name = 'AddContractSubmittedAt3020000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary
* `freight.files` rows (resource `gl_exchange`), so they only need the metadata
* a free-form upload has and a catalog-driven one does not: the uploader's own
* title, who uploaded it (the only user allowed to change it afterwards) and
* whether the customer may see it in the portal.
*/
export class AddGlExchangeDocumentFields3030000000000
implements MigrationInterface
{
name = 'AddGlExchangeDocumentFields3030000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS title varchar(300),
ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`,
);
// Every read of a thread is "all files of one resource" — index the pair.
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_files_resource_lookup
ON freight.files (resource, resource_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`,
);
await queryRunner.query(
`ALTER TABLE freight.files
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS visible_to_customer,
DROP COLUMN IF EXISTS uploaded_by_user_id,
DROP COLUMN IF EXISTS uploaded_by_name;`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTransitAgents3040000000000 implements MigrationInterface {
name = "AddTransitAgents3040000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.transit_agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(150) NOT NULL,
valid_from date NOT NULL,
valid_to date NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active
ON freight.transit_agents (is_active)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`);
}
}

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { LoginAudienceMiddleware } from './login-audience.middleware';
/**
* Touches only the DataSource, so build off the prototype rather than
* standing up a full Nest module — same pattern as
* warehouses/receive-export-paid.spec.ts.
*/
function makeMiddleware(userType: string | undefined) {
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
const middleware = Object.create(
LoginAudienceMiddleware.prototype,
) as LoginAudienceMiddleware;
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
return middleware;
}
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
return {
header: (name: string) =>
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
body: { email },
} as any;
}
describe('LoginAudienceMiddleware', () => {
it('rejects when the client app header is missing', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq(undefined), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects an unrecognized client app header', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('mobile'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an employee account signing in through the portal client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('portal'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects a customer account signing in through the backoffice client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await expect(
middleware.use(makeReq('backoffice'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows an employee account through the backoffice client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await middleware.use(makeReq('backoffice'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('allows a customer account through the portal client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('lets an unknown identifier fall through to the login handler', async () => {
const middleware = makeMiddleware(undefined);
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NextFunction, Request, Response } from 'express';
export const CLIENT_APP_HEADER = 'x-client-app';
// EUserType values from @tria-plc/api-common, duplicated here to avoid
// pulling in the full enum just for this string comparison.
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
backoffice: ['employee'],
portal: ['individual', 'external_organization'],
};
/**
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
* against email/username/phone_number only (see vendor
* findUserForLogin), with no check that the account's userType belongs on
* the app that's asking. A backoffice (employee) client presenting a
* customer's credentials — or vice versa — must not get a session.
*/
@Injectable()
export class LoginAudienceMiddleware implements NestMiddleware {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async use(req: Request, _res: Response, next: NextFunction) {
const clientApp = req.header(CLIENT_APP_HEADER);
const allowedUserTypes = clientApp
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
: undefined;
if (!allowedUserTypes) {
throw new ForbiddenException(
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
);
}
const identifier: unknown = req.body?.email;
if (typeof identifier !== 'string' || !identifier) {
// No identifier to look up — the vendor DTO validation rejects the
// request on its own.
return next();
}
const [user] = await this.dataSource.query(
`SELECT user_type AS "userType" FROM iam.users
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
[identifier],
);
if (user && !allowedUserTypes.includes(user.userType)) {
throw new ForbiddenException(
`This account cannot sign in through the ${clientApp} application`,
);
}
next();
}
}

View File

@@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Booking cancelled', msg);
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(b: Booking, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
@@ -287,11 +317,11 @@ export class BookingLifecycleNotifierService {
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
`Please review and approve it in the portal, then pay and upload the payment slip.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
@@ -331,6 +361,15 @@ export class BookingLifecycleNotifierService {
);
}
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
finalInvoiceApprovedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Final invoice approved',
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
@@ -362,6 +401,32 @@ export class BookingLifecycleNotifierService {
);
}
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
draftDeclarationReady(b: Booking, price: number, currency: string): void {
const msg =
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
this.inApp(b, 'Draft declaration ready for review', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/**
* The customer asked for a change on the draft declaration. This goes to
* STAFF, not the customer: GL Ethiopia is the one who has to send a
* corrected draft, and the clearance page is where they do it.
*/
draftDeclarationChangeRequested(b: Booking, note: string): void {
const msg =
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =

View File

@@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };
}

View File

@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };
}

View File

@@ -48,7 +48,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -70,6 +70,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
}
@@ -164,11 +165,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };
}

View File

@@ -7,7 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -56,11 +56,12 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
private isPhasedCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
@@ -376,6 +377,8 @@ export class BookingTransitionService {
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// A ONE_TIME contract closes on its single shipment being delivered.
this.events.emit('booking.completed', { bookingId });
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
@@ -491,7 +494,7 @@ export class BookingTransitionService {
operationReady?: boolean;
}> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
return this.bookingClearanceService.getClearanceView(bookingId);
}
const { inputCode, outputCode, includesCustoms } =
@@ -650,7 +653,7 @@ export class BookingTransitionService {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
@@ -732,7 +735,7 @@ export class BookingTransitionService {
}
if (
status === 'QUERIED' &&
this.isPhasedGeneralCustoms(booking) &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
@@ -755,7 +758,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -767,7 +770,7 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
if (this.isPhasedCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -817,7 +820,7 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
);

View File

@@ -823,6 +823,34 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
})
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@@ -872,6 +900,59 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
})
async uploadBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@Body('price') priceRaw: string,
@Body('currency') currency: string | undefined,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
Number(priceRaw),
currency ?? 'ETB',
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/accept')
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/change')
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
})
async requestBookingDraftDeclarationChange(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })

View File

@@ -1,8 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import {
DataSource,
DeepPartial,
EntityManager,
FindOptionsWhere,
In,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -26,6 +34,22 @@ import {
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
/** A booking is ready for a batch: commercial = signed, government = approved/paid. */
const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`;
/**
* Suspending a contract freezes its bookings, so they drop out of every
* scheduling pool. Filtering here (rather than letting the write guard throw)
* keeps the batch crons quiet — a frozen contract simply stops being a
* candidate until the suspension is lifted.
*/
const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM freight.contracts c
WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED'
))`;
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,6 +92,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/**
* Suspending a contract freezes its bookings too, so the single write path
* every booking mutation funnels through is the place to enforce it — one
* guard instead of one per transition method.
*
* The batch/scheduling pools filter suspended contracts out up front
* (see {@link excludeSuspendedContract}), so the engine and its crons never
* reach a frozen booking and this only ever fires on a user-initiated action.
*
* ponytail: the seven `manager.getRepository(Booking)` writes inside
* train-scheduling transactions bypass this — they only run on bookings the
* pool already handed out, which the filter above has excluded. Move them onto
* this repository if that ever stops holding.
*/
private async assertContractNotSuspended(id: string): Promise<void> {
const row = await this.repository
.createQueryBuilder('booking')
.select('contract.status', 'status')
.innerJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.where('booking.id = :id', { id })
.getRawOne<{ status: string }>();
if (row?.status === 'SUSPENDED') {
throw new ConflictException(
'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.',
);
}
}
override async update(
id: string,
data: DeepPartial<Booking>,
): Promise<Booking | null> {
await this.assertContractNotSuspended(id);
return super.update(id, data);
}
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
@@ -551,6 +611,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
);
}
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
async findReviewNotes(
bookingId: string,
type: ReviewNoteType,
): Promise<BookingReviewNote[]> {
return this.dataSource.getRepository(BookingReviewNote).find({
where: { bookingId, type },
order: { createdAt: 'DESC' },
});
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,
@@ -1032,7 +1103,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
'scheduleBooking.booking_id = booking.id',
)
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
.andWhere('scheduleBooking.id IS NULL')
.andWhere(NOT_ON_SUSPENDED_CONTRACT);
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
@@ -1091,10 +1163,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1130,10 +1200,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1170,10 +1238,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')

View File

@@ -62,13 +62,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
it('ONE_TIME contract shipments carry the same per-booking set', () => {
// Contracts no longer collect clearance documents — every shipment does,
// whatever kind of contract it draws on.
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
* DOMESTIC has no customs, so one shared set serves every intercity booking
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
@@ -77,16 +76,6 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
export const REVIEW_NOTE_TYPES = [
'CHANGES_REQUESTED',
'REJECTION',
'STAFF_NOTE',
/**
* The customer asked GL Ethiopia to correct the draft customs declaration
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -557,6 +557,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/**
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
* shipment in transit, Djibouti answers with a name (free text — the officer is
* not a platform user). The import declaration is blocked until `name` is set.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;

View File

@@ -15,6 +15,9 @@ const generalImportBooking = {
dutyRequired: true,
roHoldReason: null,
vesselDepartureDate: null,
// Djibouti already named the transit officer — the declaration gate is open.
transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'),
transitAssigneeName: 'Ahmed Bourhan',
} as Booking;
const generalExportBooking = {
@@ -27,6 +30,8 @@ const generalExportBooking = {
function makeService(overrides?: {
booking?: Booking;
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
@@ -38,10 +43,14 @@ function makeService(overrides?: {
};
const filesService = {
upsertByCode: jest.fn().mockResolvedValue({}),
upload: jest.fn().mockResolvedValue({}),
deleteByCode: jest.fn().mockResolvedValue(undefined),
findByResource: jest.fn().mockResolvedValue([]),
};
const fileUploadSettingsService = {
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
getByCode: overrides?.docsApproved
? jest.fn().mockResolvedValue({ fields: [] })
: jest.fn().mockRejectedValue(new Error('no setting')),
};
const workflowService = {
assertPriorCompleteForBooking: overrides?.workflowThrows
@@ -49,6 +58,7 @@ function makeService(overrides?: {
: jest.fn().mockResolvedValue(undefined),
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined),
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
@@ -91,7 +101,15 @@ function makeService(overrides?: {
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
} as never, // notifier
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
{
getAssignable: jest
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
);
return {
@@ -186,6 +204,82 @@ describe('BookingClearanceService', () => {
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an import declaration before Djibouti names the transit officer', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toThrow(/Request a transit assignee/i);
expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled();
});
it('lets the export declaration through without a transit assignee', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalExportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await service.uploadDeclaration('b-export', [
{ fieldname: 'decl' } as Express.Multer.File,
]);
expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled();
});
});
describe('transit assignee handshake', () => {
it('refuses an assignment GL Ethiopia never asked for', async () => {
const { service } = makeService({
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.assignTransitAssignee('b-general', 'Ahmed Bourhan'),
).rejects.toThrow(/has not requested a transit assignee/i);
});
it('stamps the ask and then the name', async () => {
const { service, bookingsRepository } = makeService({
booking: {
...generalImportBooking,
transitAssigneeName: null,
} as Booking,
});
await service.requestTransitAssignee('b-general', ' night shift ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeRequestedAt: expect.any(Date),
transitAssigneeRequestNote: 'night shift',
}),
);
await service.assignTransitAssignee('b-general', ' Ahmed Bourhan ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeName: 'Ahmed Bourhan',
transitAssigneeAssignedAt: expect.any(Date),
}),
);
});
});
describe('uploadReleaseOrder', () => {

View File

@@ -1,10 +1,13 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
isDraftDeclarationFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
type GlExchangeDocument,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
@@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -71,12 +76,44 @@ export interface BookingClearanceView {
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
/**
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
* file the import customs declaration before it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* Import only: the draft customs declaration GL Ethiopia sends before filing
* the real one. Present once a draft has been uploaded, regardless of
* accept state — `accepted` tells the caller which.
*/
draftDeclaration?: {
price: number;
currency: string;
files: Array<{ id: string; name: string; url: string }>;
accepted: boolean;
} | null;
/**
* The customer's open change request on the current draft declaration.
* Present only until GL sends a corrected draft; `rounds` counts how many
* times it has been sent back.
*/
draftDeclarationChangeRequest?: {
note: string;
raisedAt: string;
rounds: number;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
@@ -87,6 +124,8 @@ export interface BookingClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for this booking (what came off the train, and where). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -97,6 +136,8 @@ export interface BookingClearanceView {
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
/** GL-shared documents this booking's uploader marked visible to the customer. */
exchangeDocuments?: GlExchangeDocument[];
}
@Injectable()
@@ -111,15 +152,14 @@ export class BookingClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
private async assertPhasedCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
}
if (booking.contractKind !== 'GENERAL') {
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
}
@@ -127,7 +167,7 @@ export class BookingClearanceService {
private async loadBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
await this.assertPhasedGeneralCustoms(booking);
await this.assertPhasedCustoms(booking);
return booking;
}
@@ -214,6 +254,11 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
bookingId,
milestones,
);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
@@ -241,6 +286,12 @@ export class BookingClearanceService {
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
// GL↔GL exchange documents shared with the customer. The two desks may work
// the thread on the booking (per-booking customs) or on its contract
// (pre-booking clearance), so the customer's view spans both.
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
[bookingId, booking.contractId ?? ''],
);
return {
bookingId,
@@ -272,8 +323,21 @@ export class BookingClearanceService {
: null,
operationReady: boundary,
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
transitAssignee: {
requestedAt: booking.transitAssigneeRequestedAt
? booking.transitAssigneeRequestedAt.toISOString()
: null,
requestNote: booking.transitAssigneeRequestNote ?? null,
name: booking.transitAssigneeName ?? null,
assignedAt: booking.transitAssigneeAssignedAt
? booking.transitAssigneeAssignedAt.toISOString()
: null,
},
dutyAdvice,
draftDeclaration,
draftDeclarationChangeRequest,
workflowFiles,
exchangeDocuments,
t1,
train,
gatepassGranted: gatepass.granted,
@@ -284,6 +348,7 @@ export class BookingClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: await this.glOperationsService.offloadState(bookingId, milestones),
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -330,6 +395,46 @@ export class BookingClearanceService {
};
}
private buildDraftDeclaration(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): BookingClearanceView['draftDeclaration'] {
const uploaded = milestones.find(
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
);
if (!uploaded?.metadata) return null;
const price = uploaded.metadata.draftDeclarationPrice;
const currency = uploaded.metadata.draftDeclarationCurrency;
if (typeof price !== 'number' || typeof currency !== 'string') return null;
const draftFiles = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
const accepted =
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
'COMPLETED';
return { price, currency, files: draftFiles, accepted };
}
private async buildDraftDeclarationChangeRequest(
bookingId: string,
milestones: ClearanceMilestone[],
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (!uploaded || uploaded.status === 'COMPLETED') return null;
const notes = await this.bookingsRepository.findReviewNotes(
bookingId,
'DRAFT_DECL_CHANGE_REQUEST',
);
const latest = notes[0];
if (!latest) return null;
return {
note: latest.note,
raisedAt: latest.createdAt.toISOString(),
rounds: notes.length,
};
}
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) return true;
@@ -352,14 +457,59 @@ export class BookingClearanceService {
);
}
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
isPhasedCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) &&
booking.contractKind === 'GENERAL' &&
Boolean(booking.contractId)
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
);
}
/**
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
* in transit. The import declaration is gated on the answer, so this is the
* first thing ET does once the customer documents are approved. Re-requesting
* is allowed (a nudge) and simply restamps the ask.
*/
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
}
/**
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
}
async uploadDeclaration(
bookingId: string,
files: Express.Multer.File[],
@@ -373,6 +523,16 @@ export class BookingClearanceService {
'All required customer documents must be approved before uploading a declaration.',
);
}
// Import only: the declaration is filed against whoever physically handles
// the shipment in Djibouti, so that name must be in first. Exports have no
// such handshake — their Djibouti steps come after the declaration.
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
throw new BadRequestException(
booking.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
@@ -398,12 +558,6 @@ export class BookingClearanceService {
: ContractDocPhase.CustomerDuty,
} as never);
// Export: the declaration is the last GL ET pre-operation action — release
// immediately so the customer can proceed without a separate confirm click.
if (tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return this.bookingsService.findById(bookingId);
}
@@ -460,6 +614,124 @@ export class BookingClearanceService {
return this.bookingsService.findById(bookingId);
}
/**
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review before the real declaration is filed. Repeatable —
* each call replaces the previous draft's files/price and re-arms the step,
* which is what a re-send after a change request needs.
*/
async uploadDraftDeclaration(
bookingId: string,
files: Express.Multer.File[],
price: number,
currency: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
if (!Number.isFinite(price) || price < 0) {
throw new BadRequestException('A valid estimated price is required.');
}
// Backfills the two new milestone rows for bookings seeded before this step
// existed — a blind complete() 404s on a booking with no such row yet.
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.milestoneService.completeWithMetadataForBooking(
bookingId,
'DRAFT_DECLARATION_UPLOADED',
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
userId,
);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to accept yet.');
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
return this.bookingsService.findById(bookingId);
}
/**
* The customer sends the draft declaration back with a reason. Nothing is
* filed; the upload milestone reopens so the step becomes actionable again
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
* (same endpoint as the first time), which closes the request — the loop may
* run as many rounds as it takes.
*/
async requestDraftDeclarationChange(
bookingId: string,
note: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what needs to change so GL can correct the draft.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to request a change on yet.');
}
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
throw new BadRequestException(
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
'DRAFT_DECL_CHANGE_REQUEST',
userId,
);
// Back to GL: reopening the milestone is what re-arms the step (the
// stepper picks its active step from milestone completion).
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
@@ -664,6 +936,10 @@ export class BookingClearanceService {
'RELEASE_ORDER_SECURED',
userId,
);
// Release Order is now the last GL DJ pre-operation action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}
@@ -720,7 +996,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
@@ -733,7 +1009,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {

View File

@@ -60,6 +60,11 @@ export class BookingRequestService {
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',

View File

@@ -18,6 +18,8 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true },
DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false },
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },

View File

@@ -353,10 +353,10 @@ export class ClearanceWorkflowService {
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
if (tradeDirection === 'EXPORT') {
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone('RELEASE_ORDER_SECURED')) {
return ContractDocPhase.GlDjCollection;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
return ContractDocPhase.GlEtPostClearance;
}
@@ -450,13 +450,6 @@ export class ClearanceWorkflowService {
: 'Proceed to request operation';
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
@@ -464,6 +457,13 @@ export class ClearanceWorkflowService {
milestoneCode: 'DECLARED',
};
}
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone(EXPORT_BOUNDARY)) {
return {
actor: 'GL_ET',

View File

@@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
@@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => {
expect(contractsRepository.update).not.toHaveBeenCalled();
});
describe('completion on booking delivery', () => {
function makeDeliveryService(contract: Partial<Contract>) {
const contractsRepository = {
findById: jest.fn().mockResolvedValue(contract),
update: jest.fn().mockResolvedValue(undefined),
};
const bookingsRepository = {
findById: jest
.fn()
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ createdToStaff: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, contractsRepository };
}
it('completes a ONE_TIME contract when its booking is delivered', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
bulk: null,
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
contractKind: 'GENERAL',
status: 'CONTRACT_ACTIVE',
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(

View File

@@ -55,7 +55,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{
createdToStaff: jest.fn(),

View File

@@ -0,0 +1,74 @@
import { ForbiddenException } from '@nestjs/common';
import { ContractBookingService } from './contract-booking.service';
import { Contract } from './entities/contract.entity';
/**
* Who may open a shipment instance on a customs (Path B) contract. The customer
* initiates his own ONE_TIME customs booking and uploads the GL-input documents
* on it; GL still clears it and completes it with cargo and price. GENERAL
* customs instances come from a shipment request, and completing/creating a
* customs booking outright stays GL-only.
*/
describe('ContractBookingService — customs booking gate', () => {
function makeService() {
return new ContractBookingService(
{} as never, // contractsRepository
{} as never, // bookingsRepository
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
}
type WithPrivate = {
assertGate: (
c: Contract,
isGlActor: boolean,
isInitiate?: boolean,
) => Promise<string>;
};
const customsContract = (contractKind: 'ONE_TIME' | 'GENERAL'): Contract =>
({
id: 'c-1',
contractKind,
status: 'FULLY_EXECUTED',
customsClearingEnabled: true,
}) as Contract;
const gate = (c: Contract, isGl: boolean, isInitiate?: boolean) =>
(makeService() as never as WithPrivate).assertGate(c, isGl, isInitiate);
it('lets the customer initiate a ONE_TIME customs shipment', async () => {
await expect(gate(customsContract('ONE_TIME'), false, true)).resolves.toBe(
'CUSTOMER',
);
});
it('still lets GL initiate on the customer behalf', async () => {
await expect(gate(customsContract('ONE_TIME'), true, true)).resolves.toBe(
'GL_ET',
);
});
it('rejects a customer creating a customs booking outright (cargo + day)', async () => {
await expect(gate(customsContract('ONE_TIME'), false)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('rejects a customer initiating a GENERAL customs shipment (request only)', async () => {
await expect(
gate(customsContract('GENERAL'), false, true),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import {
ContractsRepository,
TERMINAL_BOOKING_STATUSES,
} from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
// same list.
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
@@ -94,7 +98,6 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -187,21 +190,14 @@ export class ContractBookingService {
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
// operations, and there is NO contract-level clearance cycle to link.
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// EVERY contract booking clears per booking now — both contract kinds, both
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
// workflow on this booking. Non-customs (Path A) and intercity: the customer
// uploads his own document set on the booking and Operations reviews it
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation; intercity finalize goes straight to the ride-along pool).
// So the booking is always born in the clearance gate, never in the
// operations queue, and no contract-level clearance cycle exists to link.
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
@@ -218,24 +214,11 @@ export class ContractBookingService {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
}
// No booking-window / export-space gate here any more: every contract
// booking enters the clearance gate first and is scheduled only once the
// documents are approved. Both checks run at that point instead —
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
// created with cargo) — against the day the customer actually picks.
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
@@ -265,10 +248,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -375,10 +355,7 @@ export class ContractBookingService {
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus = 'AWAITING_DOCUMENTS';
if (
withContainers &&
freightType === 'CONTAINER' &&
@@ -404,11 +381,7 @@ export class ContractBookingService {
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
@@ -417,13 +390,22 @@ export class ContractBookingService {
}
/**
* Initiate a BARE booking instance under a GENERAL non-customs contract
* (Path A per-booking self-clearance). One click, zero input: no schedule
* date, no cargo, no window check, no pricing. The instance starts in the
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
* Operations reviews and finalizes, and only then does the customer complete
* the booking (cargo + binding day + window check) via
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
* or GENERAL, customs or not. One click, zero input: no schedule date, no
* cargo, no window check, no pricing. The instance starts in the clearance
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
*
* - Path A (self-clearance): the customer initiates, uploads his clearance
* proof, Operations reviews and finalizes.
* - Path B (customs, ONE_TIME): the customer initiates too, then uploads the
* GL-input documents on the instance; GL approves them and runs the phased
* ET/DJ workflow (pre-booking milestones are seeded here). GL may still
* initiate on his behalf. GENERAL customs instances come from a shipment
* request ({@link initiateForShipmentRequest}), not from here.
*
* Only after the clearance is finalized is the booking completed (cargo +
* binding day + window check) via {@link completeUnderContract} — by the
* customer on Path A, by GL on Path B.
*/
async initiateUnderContract(
contractId: string,
@@ -434,13 +416,12 @@ export class ContractBookingService {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
if (!generalSelfClear) {
// Intercity has no shipment day to defer to, so it is booked directly with
// its cargo (the documents still live on that booking). Everything else —
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
if (contract.tradeDirection === 'DOMESTIC') {
throw new BadRequestException(
'Initiate booking applies only to general import/export contracts without customs clearing.',
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
);
}
@@ -453,12 +434,28 @@ export class ContractBookingService {
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
// completion enforces that the next booking takes the whole remainder.
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
@@ -503,6 +500,16 @@ export class ContractBookingService {
} as never),
);
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
@@ -718,6 +725,15 @@ export class ContractBookingService {
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
// ONE_TIME split chain: the instance that follows a paid partial must take
// the WHOLE outstanding remainder — same rule a booking created with cargo
// passes at creation.
if (
contract.contractKind === 'ONE_TIME' &&
(await this.hasSplitBooking(contract.id))
) {
await this.assertExactRemainder(contract, dto);
}
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
@@ -817,10 +833,7 @@ export class ContractBookingService {
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
@@ -886,33 +899,17 @@ export class ContractBookingService {
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed the full milestone timeline on the booking.
// ensure* skips codes that already exist — an initiated instance carries
// its pre-booking milestones from initiation, and a consolidation pairing
// replay must not duplicate the timeline.
// Customs runs per booking for BOTH contract kinds: seed the full milestone
// timeline on the booking. ensure* skips codes that already exist — an
// initiated instance carries its pre-booking milestones from initiation, and
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
@@ -982,10 +979,7 @@ export class ContractBookingService {
booking.contractId,
);
if (!contract) continue;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
await this.finalizeContractBooking(id, contract).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
@@ -1000,36 +994,38 @@ export class ContractBookingService {
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
private async assertGate(
contract: Contract,
isGlActor: boolean,
isInitiate = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
if (contract.status === 'SUSPENDED') {
throw new BadRequestException(
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
);
}
if (contract.customsClearingEnabled) {
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
// The customer never books a customs contract himself.
if (!isGlActor) {
// Path B — the customer OPENS the shipment instance on a ONE_TIME customs
// contract (one click, no cargo) and uploads the GL-input documents on it;
// GL still runs the phased ET/DJ clearance and completes the booking with
// cargo, day and price. A GENERAL customs instance is opened by a shipment
// request instead, and completing any customs booking stays GL-only.
const customerMayInitiate = isInitiate && contract.contractKind === 'ONE_TIME';
if (!isGlActor && !customerMayInitiate) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.contractKind === 'GENERAL') {
// GENERAL customs has NO contract clearance cycle — GL books per accepted
// shipment request while the contract is active; clearance is per booking.
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
'Contract must be active to book a shipment.',
);
}
return 'GL_ET';
}
// ONE_TIME customs — pre-booking boundary milestone must be complete.
const boundaryOk = await this.workflowService.isBoundaryComplete(
contract.id,
contract.tradeDirection,
);
if (!boundaryOk) {
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Pre-booking clearance is not complete — booking cannot be created yet.',
'Contract must be fully executed before booking a shipment.',
);
}
return 'GL_ET';
return isGlActor ? 'GL_ET' : 'CUSTOMER';
}
// Path A — customer (or staff) once the contract is executed.
@@ -1041,6 +1037,40 @@ export class ContractBookingService {
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
/**
* GL fallback worklist: executed ONE_TIME customs contracts with no live
* shipment instance yet. The customer normally opens it himself from the
* portal; this list lets GL do it on his behalf, and shows the contracts that
* are on no other queue (clearance lives on the booking, which does not exist
* yet). GENERAL customs is excluded — opened by shipment requests.
*/
async awaitingShipmentContracts(): Promise<Contract[]> {
const { items } = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['FULLY_EXECUTED'],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: 'createdAt',
sortOrder: 'DESC',
} as never);
const out: Contract[] = [];
for (const contract of items) {
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
continue;
}
// A split chain frees the slot for the remainder, so those contracts stay
// on the list even while the paid partial booking still exists.
if (await this.hasSplitBooking(contract.id)) {
out.push(contract);
continue;
}
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
}
return out;
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
@@ -1418,6 +1448,53 @@ export class ContractBookingService {
];
}
/**
* A ONE_TIME contract carries exactly one shipment: once that booking is
* delivered (COMPLETED) the contract is fulfilled and moves to
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
* blocking any further booking. A split ONE_TIME is the exception: its
* remainder chain must be rebooked and delivered first, so the contract stays
* open while the split remainder is outstanding.
*
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
* Best-effort: a status hiccup must never fail the booking that completed.
*/
@OnEvent('booking.completed')
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(payload.bookingId);
if (!booking?.contractId) return;
const contract = await this.contractsRepository.findById(booking.contractId);
if (!contract || contract.contractKind === 'GENERAL') return;
// Already closed/expired/cancelled — nothing to do.
if (isEffectivelyExpired(contract)) return;
const outstanding = await this.splitOutstanding(contract);
if (outstanding) {
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
// cap-exhaustion path below.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
);
} catch (err) {
this.logger.error(
`Could not close contract for completed booking ${payload.bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract, and under a ONE_TIME

View File

@@ -7,6 +7,7 @@ import {
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
@@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
@@ -34,7 +36,7 @@ import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -140,6 +142,8 @@ export interface ContractClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for the linked booking (null until one exists). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -165,6 +169,7 @@ export class ContractClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -433,6 +438,9 @@ export class ContractClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: cycle?.bookingId
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
: null,
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -1060,46 +1068,6 @@ export class ContractClearanceService {
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
/**
* Statuses a non-customs contract passes through around Operations
* clearance review — the set a caller may narrow {@link opsQueue} to.
*/
private static readonly OPS_CLEARANCE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'CANCELLED',
];
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
// Callers may narrow to any subset of the ops-clearance lifecycle (the
// hub's status filter sends an explicit list); anything outside the
// whitelist is dropped so this endpoint can't become a general contract
// browser. No statuses given → the original under-review queue.
const requested = (filter.statuses ?? filter.status ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) =>
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
);
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
@@ -1157,20 +1125,18 @@ export class ContractClearanceService {
}
/**
* GL Djibouti names the transit officer — free text, because the person is
* not a platform user. Answering unblocks the declaration for Ethiopia. A
* later call overwrites the name (reassignment) and re-notifies.
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(
contractId: string,
assignee: string,
transitAgentId: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (!cycle.transitAssigneeRequestedAt) {
@@ -1178,16 +1144,17 @@ export class ContractClearanceService {
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = cycle.transitAssigneeName ?? null;
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeName: assignee.trim(),
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
transitAssigneeAssignedByUserId: userId ?? null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
return updated;
}
@@ -1226,6 +1193,15 @@ export class ContractClearanceService {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
// The draft-declaration accept/change-request loop only exists on the
// booking-scoped clearance page (portal customers never see contract-scoped
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
// flow, which has no UI to complete it. Contracts seeded before this step
// existed have no such row to skip — ignore, `assertPriorComplete` below
// already tolerates a missing milestone as "not required".
await this.workflowService
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
.catch(() => undefined);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
@@ -1255,12 +1231,6 @@ export class ContractClearanceService {
});
}
// Export: the declaration is the last GL ET pre-booking action — release
// immediately so booking creation unlocks without a separate confirm click.
if (contract.tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleased(contractId, userId);
}
return this.contractsService.findById(contractId);
}
@@ -1601,6 +1571,10 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlEtOutput,
});
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
// Release Order is now the last GL DJ pre-booking action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleased(contractId, userId);
return { contract: await this.contractsService.findById(contractId), hold: false };
}
@@ -1693,80 +1667,4 @@ export class ContractClearanceService {
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const cycle = await this.contractsRepository.currentCycle(c.id);
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
filtered.push(c);
}
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
}

View File

@@ -0,0 +1,79 @@
import { ConflictException } from '@nestjs/common';
import { ContractsService } from './contracts.service';
import type { CreateContractDto } from './dto/create-contract.dto';
/**
* The duplicate guard blocks a new request only when EVERY commercial
* dimension matches a live contract — service type, operation type, contract
* kind, cargo scope and route. Any one differing must let the request through.
*/
describe('ContractsService duplicate guard', () => {
const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' };
const existing = {
id: 'c-1',
reference: 'CTR-2026-00001',
status: 'PENDING_APPROVAL',
contractValidUntil: null,
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
};
const dto = (overrides: Partial<CreateContractDto> = {}) =>
({
serviceTypeId: 'svc-1',
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
...overrides,
}) as CreateContractDto;
const guard = (input: CreateContractDto) => {
const service = new ContractsService(
{} as never,
{ findDuplicateCandidates: async () => [existing] } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return (
service as unknown as {
assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise<void>;
}
).assertNoDuplicateContract('company-1', input);
};
it('blocks an identical request', async () => {
await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException);
});
it.each([
['operation type', { tradeDirection: 'EXPORT' }],
['contract kind', { contractKind: 'GENERAL' }],
['freight type', { freightType: 'BULK' }],
['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }],
['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }],
])('allows a request with a different %s', async (_label, overrides) => {
await expect(guard(dto(overrides as Partial<CreateContractDto>))).resolves.toBeUndefined();
});
it('ignores quantity caps when comparing cargo scope', async () => {
await expect(
guard(
dto({
cargoScope: [
{ containerSize: '20ft', quantityCap: 10 },
{ containerSize: '40ft', quantityCap: 5 },
],
}),
),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -143,6 +143,33 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/** Backoffice froze the contract — every action on it is blocked until lifted. */
suspended(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} has been suspended. Reason: ${reason}. ` +
`No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`;
void this.notifyContact(c, msg, 'SUSPENDED');
this.inApp(c, 'Contract suspended', msg);
}
/** Backoffice lifted the suspension — the contract resumes where it left off. */
suspensionLifted(c: Contract, note?: string | null): void {
const msg =
`The suspension on your contract ${c.reference} has been lifted. ` +
`You can continue where you left off.${note ? ` Note: ${note}` : ''}`;
void this.notifyContact(c, msg, 'SUSPENSION LIFTED');
this.inApp(c, 'Contract suspension lifted', msg);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(
c,
'Contract cancelled by customer',
`Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`,
);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their

View File

@@ -0,0 +1,132 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* Suspension is only worth having if it is reversible and if it actually
* freezes things, and the customer's own cancel is only safe while no shipment
* is running. Those three rules are the whole feature — everything else is
* plumbing.
*/
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
status: 'CONTRACT_ACTIVE',
freightType: 'CONTAINER',
...over,
}) as Contract;
let current: Contract;
let repo: {
update: jest.Mock;
createReviewNote: jest.Mock;
countActiveBookings: jest.Mock;
};
let notifier: {
suspended: jest.Mock;
suspensionLifted: jest.Mock;
cancelledByCustomer: jest.Mock;
};
let service: ContractTransitionService;
/** A staff user holding the suspend key — authorization is tested elsewhere. */
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
};
beforeEach(() => {
current = contract();
repo = {
// Mirror the real repository: the update patches the row the next
// findById returns, so resume() reads what suspend() wrote.
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
countActiveBookings: jest.fn().mockResolvedValue(0),
};
notifier = {
suspended: jest.fn(),
suspensionLifted: jest.fn(),
cancelledByCustomer: jest.fn(),
};
// These three transitions touch only the repository, the read-back service
// and the notifier — the other 14 constructor deps stay unused, so the
// instance is built bare and only what is exercised is injected.
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('freezes at the current step and remembers where to come back to', async () => {
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith('c-1', {
status: 'SUSPENDED',
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
});
expect(notifier.suspended).toHaveBeenCalled();
});
it('restores the pre-suspension status when the suspension is lifted', async () => {
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
await service.resume('c-1', undefined, 'staff-1', staff as never);
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
statusBeforeSuspension: null,
});
});
it('refuses to suspend a contract the customer has not signed yet', async () => {
current = contract({ status: 'PENDING_APPROVAL' });
await expect(
service.suspend('c-1', 'too early', 'staff-1', staff as never),
).rejects.toThrow(/PENDING_APPROVAL/);
expect(repo.update).not.toHaveBeenCalled();
});
it('lets the customer cancel a contract with no live shipment', async () => {
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Changed supplier',
'CANCELLATION',
'user-1',
'CUSTOMER',
);
});
it('blocks the customer cancel while a shipment is still running', async () => {
repo.countActiveBookings.mockResolvedValue(2);
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/2 active shipments/);
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
current = contract({ status: 'SUSPENDED' });
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/suspended/);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -28,6 +28,7 @@ import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -38,10 +39,8 @@ import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import {
Contract,
ContractDocumentArticle,
@@ -131,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin
.join(' and ');
}
/**
* Where the backoffice may freeze a contract: every step from the customer's
* signature onward, up to (but not including) the terminal states. Suspending
* an unsigned contract is meaningless — staff reject or request changes there.
*/
export const SUSPENDABLE_CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -154,7 +168,6 @@ export class ContractTransitionService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly filesService: FilesService,
private readonly signaturesService: SignaturesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@@ -212,6 +225,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -228,6 +242,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -1262,43 +1277,16 @@ export class ContractTransitionService {
lockedAt: now,
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
// No prepay gate: the customs clearance service fee (Path B) is billed on
// the booking invoice together with the freight, so the document step
// opens immediately.
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
}
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
// intercity. A signed contract carries no clearance cycle and collects no
// documents: the shipment instance created after signature does. Customs
// (Path B): the customer initiates the booking (GENERAL: via a shipment
// request) and uploads on it, GL reviews and completes it. Self-clearance
// (Path A) and intercity: the customer initiates/books and Operations
// reviews the booking documents.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
@@ -1308,6 +1296,123 @@ export class ContractTransitionService {
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
/**
* Backoffice freeze, available at every step from the customer signature
* onward. The pre-suspension status is stashed so {@link resume} can put the
* contract back exactly where it was — a suspension you cannot lift is just a
* cancellation under another name.
*
* While SUSPENDED nothing moves: no new bookings or shipment requests
* (ContractBookingService / BookingRequestService), and no writes to the
* contract's existing bookings (BookingsRepository.update).
*/
async suspend(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'SUSPENSION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'SUSPENDED',
statusBeforeSuspension: contract.status,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspended(updated, reason);
return updated;
}
/** Lift a suspension — the contract returns to the status it was frozen at. */
async resume(
contractId: string,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, ['SUSPENDED']);
// Legacy safety net: a row suspended before the column existed has nothing
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
// contract kinds, so it is the only sane default.
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
if (note?.trim()) {
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'SUSPENSION_LIFTED',
actorId,
'STAFF',
);
}
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeSuspension: null,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspensionLifted(updated, note ?? null);
return updated;
}
/**
* Customer cancels their own contract so they can request a fresh one for the
* same lane — the duplicate-contract guard treats CANCELLED as released.
* Blocked while any booking on the contract is still live: cancelling a
* contract with cargo in motion would strand it.
*/
async cancelByCustomer(
contractId: string,
reason: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
throw new ConflictException(
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended by EDR — contact us to lift the suspension first.',
);
}
const active = await this.contractsRepository.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
'Cancel or complete them before cancelling the contract.',
);
}
const body = reason?.trim() || 'Cancelled by the customer.';
await this.contractsRepository.createReviewNote(
contractId,
body,
'CANCELLATION',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
status: 'CANCELLED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.cancelledByCustomer(updated, body);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
CancelContractDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
@@ -273,6 +276,18 @@ export class ContractsController {
return this.clearanceService.queue(filter);
}
// Must stay ABOVE @Get(':id') — declared after it, Nest matched the literal
// path as an id and ParseUUIDPipe answered 400 "uuid is expected".
@Get('awaiting-shipment')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
})
awaitingShipmentContracts() {
return this.contractBookingService.awaitingShipmentContracts();
}
@Get(':id')
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne(
@@ -453,6 +468,65 @@ export class ContractsController {
);
}
@Post(':id/suspend')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
})
suspend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SuspendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.suspend(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/resume')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
resume(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ResumeContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.resume(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
// through, everyone else must own the contract's company.
const contract = await this.contractsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.cancelByCustomer(
id,
dto.reason,
resolveAuthUserId(user),
);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@@ -763,16 +837,16 @@ export class ContractsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
assignTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.assignTransitAssignee(
id,
assignee,
transitAgentId,
resolveAuthUserId(user),
);
}
@@ -924,31 +998,8 @@ export class ContractsController {
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
}
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
etClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.etQueue(filter);
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
djClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.djQueue(filter);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
})
opsClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.opsQueue(filter);
}
@Post(':id/clearance/ops-review')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
@@ -1017,7 +1068,7 @@ export class ContractsController {
@Post(':id/bookings/initiate')
@ApiOperation({
summary:
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
})
initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@@ -1243,6 +1294,20 @@ export class ContractsController {
);
}
@Post('bookings/:bookingId/final-invoice/approve')
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
approveFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.approveFinalInvoice(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@@ -1335,7 +1400,7 @@ export class ContractsController {
) {
const file = (files ?? [])[0];
const booking = await this.bookingsService.findById(bookingId);
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) {
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
}
return this.glOperationsService.uploadDutySlip(bookingId, file);

View File

@@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeController } from './gl-exchange.controller';
import { GlExchangeService } from './gl-exchange.service';
import { BookingRequestService } from './booking-request.service';
import { BookingRequestRepository } from './booking-request.repository';
@@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,
TransitAgentsModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
@@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [ContractsController],
controllers: [ContractsController, GlExchangeController],
providers: [
ContractsService,
ContractsRepository,
@@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
GlExchangeService,
BookingRequestService,
BookingRequestRepository,
// Contract PDF providers (template resolution + render + PDF) — stateless
@@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
BookingClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlExchangeService,
],
})
export class ContractsModule {}

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { Contract } from './entities/contract.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
@@ -16,6 +17,18 @@ import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
/**
* Booking statuses that release whatever the booking was holding — contract
* capacity, the one-time active slot, the cancel gate. Everything else counts
* as a live booking.
*/
export const TERMINAL_BOOKING_STATUSES = [
'EXPIRED',
'CANCELLED',
'COMPLETED',
'REJECTED',
];
export interface ContractListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,8 +81,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
/**
* Non-terminal contracts for the same company + service type, with routes
* loaded — candidates for the duplicate-contract check on create(). Terminal
* Non-terminal contracts for the same company + service type, with routes and
* cargo scope loaded — candidates for the duplicate-contract check on
* create() (which also compares operation type, kind and scope). Terminal
* filtering happens in JS via isEffectivelyExpired (also covers the
* date-passed-but-not-yet-cron-flipped case).
*/
@@ -80,6 +94,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.where('contract.deleted_at IS NULL')
.andWhere('contract.company_id = :companyId', { companyId })
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
@@ -541,6 +556,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
// ── Review notes ──────────────────────────────────────────────────────────────
/**
* Bookings on the contract that have not reached a terminal state. Gates the
* customer's own contract cancellation (a contract carrying live cargo may
* not be cancelled) and is surfaced on the detail response so the portal can
* disable the button instead of failing the call.
*/
async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.getCount();
}
async createReviewNote(
contractId: string,
body: string,

View File

@@ -10,7 +10,7 @@ import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
//
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
@@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null {
.join(', ');
}
/**
* Order-independent identity of a cargo scope — two contracts cover the same
* cargo only when they list the same container sizes / commodities. Quantity
* caps are deliberately ignored: they size a GENERAL contract, they don't make
* it a different scope.
*/
function cargoScopeKey(
scope?: Array<
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
> | null,
): string {
if (!scope?.length) return '';
return scope
.map((row) =>
[
row.containerSize?.trim().toLowerCase() ?? '',
row.cargoTypeId ?? '',
row.cargoFreeText?.trim().toLowerCase() ?? '',
].join('|'),
)
.sort()
.join(',');
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
@@ -190,25 +214,34 @@ export class ContractsService {
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
* A live contract only blocks a new request when EVERY commercial dimension
* of the wizard matches it: service type, operation type (trade direction),
* contract kind, cargo scope and route. Change any one of them — a different
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
* customer may request another contract.
*
* A route "overlaps" if any origin/destination pair matches; cargo scope
* matches only when the two scope sets are identical (same freight type and
* the same container sizes / commodities).
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
dto: CreateContractDto,
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
dto.serviceTypeId,
);
const incomingScope = cargoScopeKey(dto.cargoScope);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
c.tradeDirection === dto.tradeDirection &&
c.contractKind === dto.contractKind &&
c.freightType === dto.freightType &&
cargoScopeKey(c.cargoScope) === incomingScope &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
dto.routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
@@ -220,7 +253,7 @@ export class ContractsService {
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
);
}
}
@@ -257,7 +290,7 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
await this.assertNoDuplicateContract(companyId, dto);
}
// Stamp the operational profile for portal scoping. A forwarder contract
@@ -813,6 +846,24 @@ export class ContractsService {
}
}
// Why the contract is frozen — shown to staff and customer alike.
if (contract.status === 'SUSPENDED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'SUSPENSION',
);
contract.latestSuspensionNote = note?.body ?? null;
} catch {
contract.latestSuspensionNote = null;
}
}
// Lets the portal disable "Cancel contract" instead of letting the customer
// click it and read a 400. The API re-checks on cancel regardless.
contract.activeBookingCount =
await this.contractsRepository.countActiveBookings(contract.id);
return contract;
}

View File

@@ -50,3 +50,17 @@ export class CancelContractDto {
@IsString()
reason?: string;
}
export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString()
@MinLength(1)
reason!: string;
}
export class ResumeContractDto {
@ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -46,6 +46,9 @@ export interface MilestoneMetadata {
declarationSerial?: string;
/** When the gate pass was physically granted (GL DJ captures the time). */
gatepassAt?: string;
/** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */
draftDeclarationPrice?: number;
draftDeclarationCurrency?: string;
}
/**

View File

@@ -13,6 +13,12 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
* correct it. One row per round — the advice/dispute loop can repeat.
*/
'DUTY_DISPUTE',
/** Backoffice froze the contract; body is the reason shown to the customer. */
'SUSPENSION',
/** Backoffice lifted a suspension; body is the optional lift note. */
'SUSPENSION_LIFTED',
/** Customer cancelled their own contract; body is their reason. */
'CANCELLATION',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
// Reversible backoffice freeze — see statusBeforeSuspension.
'SUSPENDED',
'CONTRACT_CLOSED',
'EXPIRED',
'REJECTED',
@@ -214,9 +216,21 @@ export class Contract extends BaseEntity {
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* Status the contract held when the backoffice suspended it, restored when
* the suspension is lifted. Null unless the contract is (or once was)
* SUSPENDED. A suspension without this would just be a cancellation.
*/
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@@ -343,4 +357,18 @@ export class Contract extends BaseEntity {
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
/**
* Body of the most recent SUSPENSION review note, attached by
* ContractsService.findById while the contract is SUSPENDED so both sides see
* why it was frozen. Lives in contract_review_notes, not a column here.
*/
latestSuspensionNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while
* it is > 0 (the API enforces the same). Not a column.
*/
activeBookingCount?: number;
}

View File

@@ -0,0 +1,102 @@
import { BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { GlOperationsService } from './gl-operations.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL Djibouti final invoice lands as a DRAFT: the customer must approve it
* (which issues it) before a payment slip is accepted.
*/
describe('GlOperationsService — final invoice approval', () => {
const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({
id: 'inv-1',
invoiceNumber: 'INV-1',
status,
totalAmount: 1500,
currency: 'ETB',
issuedAt,
paidAt: null,
});
let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock };
let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock };
let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock };
let service: GlOperationsService;
beforeEach(() => {
billingService = {
findInvoice: jest.fn(),
updateStatus: jest.fn().mockResolvedValue(undefined),
};
filesService = {
findByResource: jest.fn().mockResolvedValue([]),
upsertByCode: jest.fn().mockResolvedValue(undefined),
};
notifier = {
finalInvoiceApprovedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
};
const dataSource = {
getRepository: (entity: unknown) =>
entity === Booking
? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) }
: { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) },
};
service = new GlOperationsService(
dataSource as never,
filesService as never,
{} as never, // milestoneService
billingService as never,
notifier as never,
);
});
it('issues the draft on customer approval and reports approvedAt', async () => {
const issued = new Date('2026-07-28T09:00:00.000Z');
billingService.findInvoice
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft))
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued));
const summary = await service.approveFinalInvoice('bk-1', 'user-1');
expect(billingService.updateStatus).toHaveBeenCalledWith(
'inv-1',
Freight.InvoiceStatus.Issued,
);
expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled();
expect(summary.approvedAt).toBe(issued.toISOString());
});
it('is a no-op when the invoice was already approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.approveFinalInvoice('bk-1');
expect(billingService.updateStatus).not.toHaveBeenCalled();
});
it('refuses a payment slip while the invoice is still a draft', async () => {
billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft));
await expect(
service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never),
).rejects.toThrow(BadRequestException);
expect(filesService.upsertByCode).not.toHaveBeenCalled();
});
it('accepts the payment slip once approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never);
expect(filesService.upsertByCode).toHaveBeenCalledWith(
expect.objectContaining({ code: 'final_invoice_slip' }),
);
});
});

View File

@@ -0,0 +1,133 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import {
GlExchangeService,
type GlExchangeActor,
type GlExchangeSide,
} from './gl-exchange.service';
/** Either GL desk may read and post; ownership decides who may edit. */
const GL_EXCHANGE_PERMS = [
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
];
/** Multipart bodies arrive as strings — "true"/"1" mean checked. */
const asBool = (raw: string | boolean | undefined): boolean =>
raw === true || raw === 'true' || raw === '1';
@ApiTags('gl-exchange')
@ApiBearerAuth()
@Controller('gl-exchange')
export class GlExchangeController {
constructor(private readonly exchangeService: GlExchangeService) {}
@Get(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@ApiOperation({
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
})
list(
@Param('entityId', ParseUUIDPipe) entityId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.list(entityId, resolveAuthUserId(user));
}
@Post(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Share a document with the other GL desk' })
upload(
@Param('entityId', ParseUUIDPipe) entityId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.upload(
entityId,
file,
{ title, visibleToCustomer: asBool(visibleToCustomer) },
this.actor(user),
);
}
@Patch('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Uploader edits a shared document (title, visibility, file)',
})
update(
@Param('documentId', ParseUUIDPipe) documentId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string | undefined,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.update(
documentId,
{
title,
visibleToCustomer:
visibleToCustomer == null ? undefined : asBool(visibleToCustomer),
},
file,
resolveAuthUserId(user),
);
}
@Delete('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@HttpCode(204)
@ApiOperation({ summary: 'Uploader removes a shared document' })
async remove(
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.exchangeService.remove(documentId, resolveAuthUserId(user));
}
/**
* Which desk is posting. A user holding only the Djibouti actions permission
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
* posts as Ethiopia.
*/
private actor(user: TCurrentUser): GlExchangeActor {
const side: GlExchangeSide =
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
? 'DJ'
: 'ET';
return {
userId: resolveAuthUserId(user),
name: actorLabel(user) ?? null,
side,
};
}
}

View File

@@ -0,0 +1,198 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import type { FileRecord } from '../files/entities/file.entity';
/**
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
* thread is keyed by the entity the two desks are working on — a booking id on
* the per-booking clearance pages, a contract id on the pre-booking ones — so
* both desks opening the same record see the same documents.
*/
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
export type GlExchangeSide = 'ET' | 'DJ';
export interface GlExchangeActor {
userId: string;
name?: string | null;
side: GlExchangeSide;
}
export interface GlExchangeUploadInput {
title: string;
visibleToCustomer: boolean;
}
/**
* Free-form document exchange between the two Global Logistics desks. Anything
* either side needs the other to have (scans, correspondence, corrected forms)
* lands here under a title they choose, instead of a fixed clearance slot.
*
* Rules, all enforced here rather than in the UI:
* - both desks read every document in a thread, whoever uploaded it;
* - only the uploader may retitle, replace or remove one;
* - the customer sees only what its uploader marked visible.
*/
@Injectable()
export class GlExchangeService {
constructor(private readonly filesService: FilesService) {}
/** Every document on one thread, newest first, from a GL desk's view. */
async list(
entityId: string,
viewerId: string,
): Promise<Freight.GlExchangeDocument[]> {
const records = await this.filesService.findByResource(
entityId,
GL_EXCHANGE_RESOURCE,
);
return this.sort(records.map((r) => this.toDto(r, viewerId)));
}
/**
* The customer-facing slice across several threads (a booking and the
* contract it belongs to). Never exposes internal documents, and never marks
* anything editable — the customer is not a GL desk.
*/
async listVisibleToCustomer(
entityIds: string[],
): Promise<Freight.GlExchangeDocument[]> {
const ids = [...new Set(entityIds.filter(Boolean))];
if (ids.length === 0) return [];
const grouped = await this.filesService.findByResourceIdsGrouped(
ids,
GL_EXCHANGE_RESOURCE,
);
const visible = [...grouped.values()]
.flat()
.filter((r) => r.visibleToCustomer);
return this.sort(visible.map((r) => this.toDto(r, null)));
}
async upload(
entityId: string,
file: Express.Multer.File | undefined,
input: GlExchangeUploadInput,
actor: GlExchangeActor,
): Promise<Freight.GlExchangeDocument> {
const title = input.title?.trim();
if (!title) throw new BadRequestException('A document title is required.');
if (!file) throw new BadRequestException('A file is required.');
const record = await this.filesService.upload({
resourceId: entityId,
resource: GL_EXCHANGE_RESOURCE,
// No fixed slot exists for these — `code` carries the uploading desk, so
// a document's origin survives even if the uploader leaves the org.
code: actor.side,
file,
title,
visibleToCustomer: input.visibleToCustomer,
uploadedByUserId: actor.userId,
uploadedByName: actor.name ?? null,
});
return this.toDto(record, actor.userId);
}
/**
* Retitle, re-share or replace a document. Uploader only — the other desk
* reads it but never edits it. A replacement file supersedes the old record
* (soft-deleted, bytes kept) and carries its metadata forward.
*/
async update(
documentId: string,
patch: { title?: string; visibleToCustomer?: boolean },
file: Express.Multer.File | undefined,
actorId: string,
): Promise<Freight.GlExchangeDocument> {
const record = await this.assertUploader(documentId, actorId);
const title = patch.title?.trim();
if (patch.title != null && !title) {
throw new BadRequestException('A document title is required.');
}
if (file) {
const replacement = await this.filesService.upload({
resourceId: record.resourceId,
resource: GL_EXCHANGE_RESOURCE,
code: record.code,
file,
title: title ?? record.title,
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
uploadedByUserId: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
});
await this.filesService.remove(record.id);
return this.toDto(replacement, actorId);
}
const updated = await this.filesService.updateMeta(record.id, {
...(title ? { title } : {}),
...(patch.visibleToCustomer != null
? { visibleToCustomer: patch.visibleToCustomer }
: {}),
});
return this.toDto(updated, actorId);
}
/** Uploader-only removal (soft delete — the stored bytes are kept). */
async remove(documentId: string, actorId: string): Promise<void> {
const record = await this.assertUploader(documentId, actorId);
await this.filesService.remove(record.id);
}
private async assertUploader(
documentId: string,
actorId: string,
): Promise<FileRecord> {
const record = await this.filesService.findById(documentId);
if (record.resource !== GL_EXCHANGE_RESOURCE) {
throw new NotFoundException(`Exchange document ${documentId} not found`);
}
if (record.uploadedByUserId !== actorId) {
throw new ForbiddenException(
'Only the person who uploaded this document can change it.',
);
}
return record;
}
private sort(
docs: Freight.GlExchangeDocument[],
): Freight.GlExchangeDocument[] {
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
}
private toDto(
record: FileRecord,
viewerId: string | null,
): Freight.GlExchangeDocument {
return {
id: record.id,
entityId: record.resourceId,
// Pre-title rows (none in practice) fall back to the filename so a list
// never renders a blank row.
title: record.title ?? record.name,
side: record.code === 'DJ' ? 'DJ' : 'ET',
visibleToCustomer: record.visibleToCustomer,
uploadedById: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
uploadedAt: record.createdAt.toISOString(),
file: {
id: record.id,
name: record.name,
url: record.url,
size: record.size,
mimeType: record.mimeType,
},
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
};
}
}

View File

@@ -282,6 +282,85 @@ export class GlOperationsService {
};
}
/**
* Offload facts for a booking, read-only: what came off the train at its
* destination (containers, wagons, tonnes) and where the goods went. Sourced
* from the booking's warehouse-inventory row — written by the auto-unload
* that runs on train arrival for both directions.
*/
async offloadState(
bookingId: string,
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
): Promise<Freight.ClearanceOffloadState> {
const [row]: Array<{
destination: string | null;
containers: number;
wagons: number;
bookedWeight: string | null;
inventoryStatus: string | null;
unloadedAt: Date | null;
grnNumber: string | null;
offloadedWeight: string | null;
warehouse: string | null;
warehouseYard: string | null;
zone: string | null;
}> = await this.dataSource.query(
`SELECT COALESCE(dy.label, dy.code) AS "destination",
(SELECT COUNT(*)::int
FROM freight.booking_container bc
JOIN freight.booking_container_units bcu
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
(SELECT COUNT(*)::int
FROM freight.wagon_booking_allocations wba
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
b.cargo_total_weight_vgm AS "bookedWeight",
inv.status AS "inventoryStatus",
inv.unloaded_at AS "unloadedAt",
inv.grn_number AS "grnNumber",
inv.weight AS "offloadedWeight",
wh.name AS "warehouse",
wy.name AS "warehouseYard",
wz.name AS "zone"
FROM freight.bookings b
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN LATERAL (
SELECT i.*
FROM freight.warehouse_inventory i
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
LIMIT 1
) inv ON TRUE
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
const offloadedAt =
milestone?.status === 'COMPLETED' && milestone.triggeredAt
? new Date(milestone.triggeredAt).toISOString()
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
// The warehouse records the real offloaded tonnage; before it does, the
// booked VGM is the best number we have.
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' ');
return {
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
offloadedAt,
destination: row?.destination ?? null,
containers: row?.containers ?? 0,
wagons: row?.wagons ?? 0,
weightTons: weight || null,
grnNumber: row?.grnNumber ?? null,
location: location || null,
inventoryStatus: row?.inventoryStatus ?? null,
};
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
@@ -381,8 +460,9 @@ export class GlOperationsService {
/**
* GL Djibouti raises the post-offload final invoice (export): manual amount +
* attached invoice document. The customer pays offline and attaches a slip;
* GL (ET or DJ) then confirms to settle it.
* attached invoice document. It is issued as a DRAFT the customer must approve
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
* confirms to settle it.
*/
async createFinalInvoice(
bookingId: string,
@@ -445,7 +525,8 @@ export class GlOperationsService {
amount: input.amount,
},
],
status: Freight.InvoiceStatus.Issued,
// DRAFT until the customer approves it — approveFinalInvoice issues it.
status: Freight.InvoiceStatus.Draft,
});
await this.filesService.upsertByCode({
@@ -467,6 +548,40 @@ export class GlOperationsService {
return summary;
}
/**
* Customer approves the drafted final invoice — issues it, which is what
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
*/
async approveFinalInvoice(
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
GL_FINAL_INVOICE_TYPE,
);
if (!invoice) {
throw new BadRequestException('No final invoice has been raised for this shipment.');
}
if (
invoice.status === Freight.InvoiceStatus.Cancelled ||
invoice.status === Freight.InvoiceStatus.Expired
) {
throw new BadRequestException('The final invoice is no longer payable.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
this.notifier.finalInvoiceApprovedToStaff(booking);
}
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice not found.');
return summary;
}
/** Customer attaches the payment slip for the final invoice. */
async uploadFinalInvoiceSlip(
bookingId: string,
@@ -483,6 +598,11 @@ export class GlOperationsService {
if (!invoice) {
throw new BadRequestException('No final invoice has been issued for this shipment.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
'Approve the final invoice before attaching a payment slip.',
);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('The final invoice is already paid.');
}
@@ -688,6 +808,8 @@ export class GlOperationsService {
description: line?.description ?? null,
invoiceFile: toRef('final_invoice'),
slipFile: toRef('final_invoice_slip'),
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
};
}

View File

@@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
draftDeclarationFileLabel,
isDeclarationFileCode,
isDraftDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
@@ -72,6 +74,52 @@ export async function persistDeclarationUploads(
);
}
/** Require at least one draft declaration file in the upload batch. */
export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
}
/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDraftDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `draft_declaration_${index}`,
}));
}
/** Replace all draft declaration files on a resource with a new multi-file upload batch. */
export async function persistDraftDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDraftDeclarationFieldNames(files);
assertDraftDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `draft_declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
@@ -341,6 +389,22 @@ export function buildWorkflowFiles(
});
});
const extraDraftDeclarations = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDraftDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: draftDeclarationFileLabel(index),
uploadedBy: 'gl_et',
category: 'draft_declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))

View File

@@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.Mock;
transitAssigneeAssigned: jest.Mock;
};
let transitAgentsService: { getAssignable: jest.Mock };
let service: ContractClearanceService;
const cycle = (over: Record<string, unknown> = {}) => ({
@@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
};
transitAgentsService = {
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
};
service = new ContractClearanceService(
repo as never,
contractsService as never,
@@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
{} as never,
notifier as never,
transitAgentsService as never,
);
});
@@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => {
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
@@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeName: 'Ahmed Bourhan',
}),
);
transitAgentsService.getAssignable.mockResolvedValue({
id: 'agent-2',
name: 'Fatouma Ali',
});
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.anything(),
@@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => {
);
});
it('refuses an empty name', async () => {
it('refuses a suspended or out-of-window agent', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
transitAgentsService.getAssignable.mockRejectedValue(
new BadRequestException('suspended'),
);
await expect(
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses before Ethiopia has asked', async () => {
await expect(
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toThrow(/not requested/i);
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
});
});

View File

@@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity {
/** Why the file was replaced — shown on the document's version history. */
@Column({ name: "replace_reason", type: "text", nullable: true })
replaceReason!: string | null;
/**
* Free-text label chosen by the uploader, when the document has no fixed slot
* (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every
* catalog-driven upload, whose label comes from its code.
*/
@Column({ name: "title", type: "varchar", length: 300, nullable: true })
title!: string | null;
/** Uploader's choice to share the document with the customer's portal. */
@Column({ name: "visible_to_customer", type: "boolean", default: false })
visibleToCustomer!: boolean;
/** Who uploaded it — the only user allowed to edit or remove it afterwards. */
@Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true })
uploadedByUserId!: string | null;
/** Uploader's display name, resolved once so lists need no IAM lookup. */
@Column({
name: "uploaded_by_name",
type: "varchar",
length: 200,
nullable: true,
})
uploadedByName!: string | null;
}

View File

@@ -15,6 +15,11 @@ export interface CreateFileInput {
resource: string;
code: string;
file: Express.Multer.File;
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
title?: string | null;
visibleToCustomer?: boolean;
uploadedByUserId?: string | null;
uploadedByName?: string | null;
}
/**
@@ -101,9 +106,27 @@ export class FilesService {
url,
size: file.size,
mimeType: file.mimetype,
title: input.title ?? null,
visibleToCustomer: input.visibleToCustomer ?? false,
uploadedByUserId: input.uploadedByUserId ?? null,
uploadedByName: input.uploadedByName ?? null,
});
}
/**
* Edit the uploader-authored metadata of a stored file (title, customer
* visibility). Bytes are untouched — callers replacing content upload a new
* record instead.
*/
async updateMeta(
id: string,
patch: { title?: string; visibleToCustomer?: boolean },
): Promise<FileRecord> {
const updated = await this.filesRepository.update(id, patch);
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Replace the file stored under a resource + code (e.g. contract PDF). The
* previous version is retired, not destroyed — pass `replacedBy` to record who

View File

@@ -327,6 +327,8 @@ export class LastMileService {
*/
async arrivalTrucksForBooking(bookingId: string): Promise<
Array<{
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -359,6 +361,7 @@ export class LastMileService {
: [];
const out: Array<{
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -386,6 +389,7 @@ export class LastMileService {
}
}
out.push({
lastMileId: lm.id,
vehicleId: vehicle.id,
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
trailerPlateNumber: vehicle.trailerPlateNo || null,

View File

@@ -1,13 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
// Extends the shared pagination DTO for `page`/`pageSize`/`search`; those are
// only read by `GET /locomotives/paged` — the plain list ignores them.
export class FilterLocomotivesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
@@ -47,4 +50,14 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsUUID()
excludeTrainId?: string;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdTo?: string;
}

View File

@@ -24,6 +24,14 @@ export class LocomotivesController {
return this.locomotivesService.findAll(filter);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('paged')
@StaffReference()
@ApiOperation({ summary: 'List locomotives, paginated ({items, meta})' })
findAllPaged(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAllPaged(filter);
}
@Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a locomotive by ID' })

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
@@ -16,18 +16,22 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
}
/**
* List locomotives for the train-builder coupling picker: the usual
* status/type/yard filters, plus optional exclusion of any loco already
* coupled to a built train. `keepTrainId` spares that one train's own locos
* from the exclusion so they stay selectable while editing its consist.
* Filter/sort builder shared by the coupling picker and the paginated list:
* the usual status/type/yard filters, free-text over code + name, a
* registration-day range, and optional exclusion of any loco already coupled
* to a built train. `keepTrainId` spares that one train's own locos from the
* exclusion so they stay selectable while editing its consist.
*/
findForCoupling(opts: {
buildListQuery(opts: {
status?: LocomotiveStatus;
locomotiveType?: LocomotiveType;
currentYardId?: string;
excludeCoupled?: boolean;
keepTrainId?: string;
}): Promise<Locomotive[]> {
search?: string;
createdFrom?: string;
createdTo?: string;
}): SelectQueryBuilder<Locomotive> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
@@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
if (opts.currentYardId)
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
const search = opts.search?.trim();
if (search) {
qb.andWhere('(locomotive.code ILIKE :search OR locomotive.name ILIKE :search)', {
search: `%${search}%`,
});
}
// Registration-day range, both ends inclusive (the UI picks whole days).
if (opts.createdFrom) {
qb.andWhere('locomotive.createdAt >= CAST(:createdFrom AS date)', {
createdFrom: opts.createdFrom,
});
}
if (opts.createdTo) {
qb.andWhere("locomotive.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
createdTo: opts.createdTo,
});
}
if (opts.excludeCoupled) {
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
// consist being edited still lists its current locomotives.
@@ -53,7 +76,11 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
return qb;
}
findForCoupling(opts: Parameters<LocomotivesRepository['buildListQuery']>[0]): Promise<Locomotive[]> {
return this.buildListQuery(opts).getMany();
}
/**

View File

@@ -1,6 +1,9 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -53,6 +56,21 @@ export class LocomotivesService {
});
}
/** Same filters as `findAll` plus search/date range, on the shared list envelope. */
findAllPaged(filter: FilterLocomotivesDto): Promise<PaginatedResponse<Locomotive>> {
const qb = this.locomotivesRepository.buildListQuery({
status: filter.status as LocomotiveStatus | undefined,
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
currentYardId: filter.currentYardId,
excludeCoupled: filter.excludeCoupled,
keepTrainId: filter.excludeTrainId,
search: filter.search,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
});
return paginateQuery(qb, filter);
}
/** Default max pull weight (tons) applied when the caller omits it. */
private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;

View File

@@ -1,14 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { RouteStatus } from '../entities/route.entity';
export class FilterRoutesDto {
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
@IsOptional()
@IsString()
search?: string;
// `search` (origin/destination/milestone yard codes and names) plus
// `page`/`pageSize` come from the shared pagination DTO; the page window is only
// read by `GET /routes/paged`.
export class FilterRoutesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional()
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])

View File

@@ -21,6 +21,13 @@ export class RoutesController {
return this.routesService.findAll(filter);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('paged')
@ApiOperation({ summary: 'List routes, paginated ({items, meta})' })
findAllPaged(@Query() filter: FilterRoutesDto) {
return this.routesService.findAllPaged(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get route by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -4,9 +4,10 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { PaginatedResponse, TrainScheduleStatus } from '@edr/types';
import { DataSource, In, Not } from 'typeorm';
import { paginateArray } from '../../common/utils/pagination.util';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
@@ -68,6 +69,18 @@ export class RoutesService {
});
}
/**
* `findAll` on the shared `{items, meta}` envelope.
*
* ponytail: slices in memory — the corridor table is small (tens of rows) and
* both the ordering (formatted "A → B → C" label) and the search span the
* milestone collection, which a single SQL page window cannot express. Move to
* a query builder if routes ever grow past a few hundred.
*/
async findAllPaged(filter: FilterRoutesDto): Promise<PaginatedResponse<Route>> {
return paginateArray(await this.findAll(filter), filter);
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },

View File

@@ -1,5 +1,6 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonStockLedger } from './wagon-stock-ledger.util';
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
@@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => {
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
wagonStockForSchedule: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
@@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
// No physical stock configured → the wagon-type gate stands down and these
// specs keep testing the abstract capacity budget on its own.
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'YARD',
remainingByTypeId: new Map<string, number>(),
codesByTypeId: new Map<string, string>(),
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => {
};
await fn(manager);
}),
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
// Empty = unresolvable, so the physical-stock gate stands down and these
// specs keep exercising the abstract capacity budget alone.
query: jest.fn().mockResolvedValue([]),
};
notifier = {
@@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
return genericRepo;
}),
transaction: jest.fn(),
query: jest.fn().mockResolvedValue([]),
};
const service = new BookingBatchService(
dataSource as never,
@@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => {
});
});
});
/**
* The reported failure: a train advertising 20 free wagons where only 16 are of
* the type the booking can ride. Selecting all 20 took the customer's money for
* space that never existed and then stalled at allocation on wagon 17.
*/
describe('BookingBatchService — physical wagon-type gate', () => {
const NW5 = 'wagon-type-nw5';
const PW2 = 'wagon-type-pw2';
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
const internals = (svc: BookingBatchService) =>
svc as unknown as {
hasWagonStock: (
stock: WagonStockLedger,
ids: string[],
needed: number,
leg: { fromEdge: number; toEdge: number },
) => boolean;
maybeOfferPartial: (
booking: Booking,
isPair: boolean,
candidates: unknown[],
need: { wagons: number; weightTons: number; lengthMeters: number },
ids: string[],
) => Promise<boolean>;
tryPartialOffer: unknown;
isSplitEligible: unknown;
};
const service = () =>
new BookingBatchService(
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
const svc = internals(service());
const stock = mixedStock();
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
// A booking that may ride either type sees all 20.
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
});
it('stands down when the booking has no allowed wagon type configured', () => {
// Unresolvable configuration must not strand every booking that uses it —
// the abstract capacity budget still governs.
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
});
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
const svc = service();
const inner = internals(svc);
// Isolate the sizing decision: eligibility and offer creation are covered
// elsewhere, what matters here is the room handed to tryPartialOffer.
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
// 20 abstract slots free, weight and length wide open.
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const offered = await inner.maybeOfferPartial(
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
false,
[candidate],
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
[NW5],
);
expect(offered).toBe(true);
// 16, not the 20 free slots — the customer is billed for what can be loaded.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
});

View File

@@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay } from './batch-window.util';
import {
TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
@@ -87,6 +90,7 @@ import {
OverageTolerance,
stopYardsFor,
} from './corridor-capacity.util';
import { WagonStockLedger } from './wagon-stock-ledger.util';
export type { Capacity } from './corridor-capacity.util';
@@ -166,6 +170,13 @@ export type BookingAllocationStatus =
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle this booking entered the pool in (derived from
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
* bookings within a cycle only — an earlier cycle always boards before a later
* one regardless of score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
@@ -1320,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
}
}
const cycleOf = await this.windowCycleIndexer(s);
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
@@ -1619,6 +1632,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const stock = await this.stockLedgerFor(schedule, budget);
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL");
@@ -1629,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool);
let armed = false;
let preempted = false;
@@ -1655,14 +1670,19 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// Abstract room AND real wagons of a type this booking can ride — see
// fillRouteDayInternal for why both gates are needed.
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
`stocked=${stocked}`,
);
if (!budget.fits(need, leg)) {
if (!budget.fits(need, leg) || !stocked) {
if (isGov) {
const freed = await this.preemptForGovernment(
scheduleId,
@@ -1677,16 +1697,19 @@ export class BookingBatchService implements OnModuleInit {
// Doesn't fit whole. A split-eligible import booking is offered the part
// that fits in the remaining room (top-up path splits the boundary
// booking, mirroring fillRouteDay); otherwise skip and try the next.
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
id: scheduleId,
budget,
armed,
};
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
const cand: {
id: string;
budget: CorridorBudget;
armed: boolean;
stock: WagonStockLedger;
} = { id: scheduleId, budget, armed, stock };
if (
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
) {
armed = cand.armed;
continue;
}
continue; // skip a unit that exceeds weight/length/wagons, try the next
continue; // skip a unit that exceeds weight/length/wagons/stock, try the next
}
}
@@ -1704,6 +1727,8 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
budget.subtract(need, leg);
// Hold the physical wagons too — the next unit must not re-count them.
stock.consume(wagonTypeIds, need.wagons, leg);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
@@ -1823,14 +1848,20 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
// Live per-schedule corridor budget + arm/changed flags, in departure order.
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
// flags, in departure order.
const trains: Array<{
id: string;
budget: CorridorBudget;
stock: WagonStockLedger;
armed: boolean;
changed: boolean;
}> = [];
// The day group shares one booking window (route+day grouping), so any
// member's window grid stands for the pool's cycle derivation.
let cycleSchedule: TrainSchedule | null = null;
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1841,10 +1872,12 @@ export class BookingBatchService implements OnModuleInit {
);
continue;
}
cycleSchedule ??= schedule;
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false, changed: false });
const stock = await this.stockLedgerFor(schedule, budget);
trains.push({ id, budget, stock, armed: false, changed: false });
}
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
@@ -1860,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit {
// BULK bookings only get their real (wagon-derived) priority score now, at
// batch time — stamp it and re-rank before the fill consumes the pool.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(
pool,
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
@@ -1884,12 +1920,20 @@ export class BookingBatchService implements OnModuleInit {
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
t.budget.legOf(booking.originYardId, booking.destinationYardId);
// Consolidated pairs share one wagon set; the primary's types stand for both.
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// First train (earliest departure) whose corridor carries this booking's
// leg and still fits it as-is.
// leg, still fits it as-is AND physically holds enough wagons of a type the
// booking can ride. Both gates matter: abstract room without the right
// wagon type is space the allocator can never turn into a loaded consist.
let target = trains.find((t) => {
const leg = legOn(t);
return leg != null && t.budget.fits(need, leg);
return (
leg != null &&
t.budget.fits(need, leg) &&
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
);
});
// Per-unit trace: chosen train + each train's remaining room on this leg.
@@ -1934,7 +1978,13 @@ export class BookingBatchService implements OnModuleInit {
// already consumed most of the room). Consolidated pairs / government /
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
const offered = await this.maybeOfferPartial(
booking,
isPair,
trains,
need,
wagonTypeIds,
);
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
@@ -1964,6 +2014,9 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
// Hold the physical wagons too, so the next unit in this pass sees them
// gone — otherwise two bookings both "fit" the same 16 NW5.
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
target.changed = true;
reservedThisPass += 1;
} catch (err) {
@@ -2027,14 +2080,32 @@ export class BookingBatchService implements OnModuleInit {
private async maybeOfferPartial(
booking: Booking,
isPair: boolean,
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
candidates: Array<{
id: string;
budget: CorridorBudget;
armed: boolean;
stock?: WagonStockLedger;
}>,
need: Capacity,
wagonTypeIds: string[] = [],
): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false;
const target = candidates
.map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
if (!leg) return null;
const room = c.budget.remainingFor(leg);
// The offer may never exceed the wagons that physically exist in a type
// this booking can ride. This is what turns "20 free wagons, only 16 of
// them NW5" into an offer for 16 — the customer pays for 16 and the
// other 4 leave as the usual remainder booking, instead of paying for
// 20 and stalling at allocation on wagon 17.
const physical = wagonTypeIds.length
? c.stock?.availableFor(wagonTypeIds, leg)
: undefined;
const wagons =
physical == null ? room.wagons : Math.min(room.wagons, physical);
return { c, leg, room: { ...room, wagons } };
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
@@ -2047,6 +2118,7 @@ export class BookingBatchService implements OnModuleInit {
);
if (!offered) return false;
target.c.budget.subtract(offered, target.leg);
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
target.c.armed = true;
return true;
}
@@ -3204,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit {
}
}
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
private resortPoolByPriority(pool: Booking[]): void {
/**
* Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
* booking-window cycle it arrived in: the last window whose open is at/before
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
* cycle that just closed). The cycle grid comes from the schedule's frozen
* window-rule snapshot — the exact windows the cycle engine runs.
*/
private async windowCycleIndexer(
schedule: TrainSchedule,
): Promise<(ts: Date | null | undefined) => number> {
if (!schedule.scheduledDepartureDate) return () => 0;
let starts: number[];
try {
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const windows = listConfigBookingWindows(
schedule.direction,
schedule.scheduledDepartureDate,
{
...cfg,
reopenGapMinutes:
schedule.ruleReopenDelayMinutes ??
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
},
);
starts = windows.map((w) => w.start.getTime());
} catch (err) {
// A failed cycle derivation must never block the batch — fall back to one
// flat cycle (pure priority order, the old behaviour).
this.logger.warn(
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
`${(err as Error).message}`,
);
return () => 0;
}
return (ts) => {
if (!ts) return 0;
const ms = ts.getTime();
let idx = 0;
for (let i = 0; i < starts.length; i += 1) {
if (ms >= starts[i]) idx = i;
}
return idx;
};
}
/**
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
* only within the cycle they arrived in — an earlier cycle's booking always
* outranks a later cycle's, whatever the scores), then priority score, then
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
*/
private resortPoolByPriority(
pool: Booking[],
cycleOf: (ts: Date | null | undefined) => number = () => 0,
): void {
pool.sort(
(a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) ||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
@@ -3468,6 +3595,131 @@ export class BookingBatchService implements OnModuleInit {
return dims.length ? dims : [fallback];
}
/**
* Physical wagon-type stock for one schedule, on the same corridor edges its
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
* batch counts exactly the wagons the allocator will later plan against.
*/
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
);
}
/**
* Whether the train holds enough PHYSICAL wagons of the types this booking may
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
* abstract budget still governs, and a mis-configured cargo type must not
* silently strand every booking that uses it.
*/
private hasWagonStock(
stock: WagonStockLedger,
wagonTypeIds: string[],
wagonsNeeded: number,
leg: CorridorLeg,
): boolean {
if (!wagonTypeIds.length) return true;
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
}
private allowedWagonTypeCache: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
expiresAt: number;
} | null = null;
/**
* Wagon-type ids each cargo / container type may ride, read straight from the
* join tables.
*
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
* a hot path. So the pool's booking entities carry the type FK but not the
* allowed list, and resolving it per booking through the relation would come
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
* give the same answer without touching the pool query.
*/
private async loadAllowedWagonTypeIds(): Promise<{
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
}> {
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
return this.allowedWagonTypeCache;
}
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
// scheduling service — the allocator will not plan against them either.
const [cargoRows, containerRows]: [
Array<{ typeId: string; wagonTypeId: string }>,
Array<{ typeId: string; wagonTypeId: string }>,
] = await Promise.all([
this.dataSource.query(
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.cargo_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
this.dataSource.query(
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.container_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
]);
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
const map = new Map<string, string[]>();
for (const row of rows) {
const list = map.get(row.typeId) ?? [];
list.push(row.wagonTypeId);
map.set(row.typeId, list);
}
return map;
};
const value = {
byCargoTypeId: collect(cargoRows),
byContainerTypeId: collect(containerRows),
};
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
* caller must then skip the physical-stock gate rather than block the booking
* on missing configuration.
*/
private allowedWagonTypeIdsFor(
booking: Booking,
allowed: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): string[] {
if (booking.freightType === "BULK") {
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
}
const ids = new Set<string>();
for (const line of booking.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (!containerTypeId) continue;
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
ids.add(id);
}
}
return [...ids];
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.

View File

@@ -5,6 +5,7 @@ import {
NotFoundException,
Optional,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
@@ -48,6 +49,7 @@ export class BookingJourneyService {
@InjectDataSource() private readonly dataSource: DataSource,
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -145,6 +147,12 @@ export class BookingJourneyService {
});
});
// Intercity ends here — a ONE_TIME contract closes on its shipment being
// delivered (import/export emit this from booking-transition.complete).
if (nextStatus === 'COMPLETED') {
this.events.emit('booking.completed', { bookingId });
}
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'
@@ -303,6 +311,12 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
for (const row of rows) {
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
}
return rows.map((r) => r.id);
}
@@ -487,7 +501,13 @@ export class BookingJourneyService {
currentYardId: booking.destinationYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
status: Freight.WagonStatus.Available,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool. Marking a
// coupled wagon AVAILABLE made it show up in the train-builder's
// "available wagons" picker, where attaching it always 409'd.
status: wagon.trainId
? Freight.WagonStatus.Assigned
: Freight.WagonStatus.Available,
});
}
}

View File

@@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto {
type: [String],
format: 'uuid',
description:
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.',
})
@IsOptional()
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds?: string[];

View File

@@ -5,7 +5,7 @@ import {
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
combinedLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
@@ -197,42 +197,75 @@ describe('train-capacity.util', () => {
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('takes the weakest locomotive across a multi-locomotive set', () => {
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
expect(limits?.overageToleranceTons).toBe(180);
// A single locomotive is just its own limit — no doubling, no halving.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
]);
expect(limits?.maxTrainLengthMeters).toBe(700);
expect(limits?.overageToleranceMeters).toBe(5);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
// keep the 90 rather than collapse to 0 — an unset value abstains.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = minLocomotiveLimits([
const none = combinedLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('reports no pull limit when NO locomotive has one configured', () => {
// Summing must not turn "unset" into 0 and strand every booking; an
// all-unset set keeps the old "no opinion" behaviour.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
]);
expect(limits?.maxPullWeightTons).toBe(Infinity);
// One configured, one not → only the configured one contributes.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3500,
maxPullWeightTons: 3550,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});

View File

@@ -256,27 +256,40 @@ function round3(value: number): number {
}
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
* Effective limits for a train set, per axis:
*
* - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull
* 3500T. Only CONFIGURED pull weights are summed; a set with none configured
* reports Infinity (no opinion), exactly as before.
* - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its
* own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0).
* - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not
* a haulage one: coupling a second locomotive does not lengthen the track, so
* the most restrictive locomotive still governs (and its tolerance with it).
*
* Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
export function combinedLocomotiveLimits(
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
const configuredPulls = locomotives
.map((l) => num(l.maxPullWeightTons))
.filter((v) => v > 0);
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
),
maxPullWeightTons: configuredPulls.length
? round3(configuredPulls.reduce((sum, v) => sum + v, 0))
: Infinity,
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
),
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
// tolerance set has no opinion, it does not zero out the others.
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)),
// Paired with the length axis, so it stays the weakest CONFIGURED value — a
// locomotive with no tolerance set has no opinion, it does not zero the others.
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
};
}
@@ -286,10 +299,16 @@ function minConfigured(values: Array<number | null | undefined>): number {
return configured.length ? Math.min(...configured) : 0;
}
function sumConfigured(values: Array<number | null | undefined>): number {
const configured = values.filter((v) => v != null).map((v) => num(v));
return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0;
}
/**
* Effective limits for a whole train set: min across its linked locomotives,
* falling back to the legacy single `locomotive` column for sets created
* before multi-loco support. Null when the set has no locomotive at all.
* Effective limits for a whole train set: {@link combinedLocomotiveLimits} over
* its linked locomotives, falling back to the legacy single `locomotive` column
* for sets created before multi-loco support. Null when the set has no
* locomotive at all.
*/
export function trainSetLocomotiveLimits(
trainSet?: {
@@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits(
: trainSet.locomotive
? [trainSet.locomotive]
: [];
return minLocomotiveLimits(pool);
return combinedLocomotiveLimits(pool);
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */

View File

@@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
bookingCargoTons,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
combinedLocomotiveLimits,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
@@ -1300,9 +1300,9 @@ export class TrainSchedulingService {
.slice()
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotiveId);
if (locomotiveIds.length < 2) {
if (locomotiveIds.length < 1) {
throw new BadRequestException(
`Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
);
}
if (builtTrain.currentYardId !== route.originYardId) {
@@ -1323,8 +1323,8 @@ export class TrainSchedulingService {
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
}
@@ -1382,7 +1382,7 @@ export class TrainSchedulingService {
builtTrain?.id ?? null,
);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// Every schedule starts with a CLOSED customer window; the window engine opens
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
@@ -1572,7 +1572,7 @@ export class TrainSchedulingService {
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
@@ -2754,11 +2754,13 @@ export class TrainSchedulingService {
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
booking: allocation.booking,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
containerItems: allocation.containerItems ?? [],
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
@@ -2839,6 +2841,7 @@ export class TrainSchedulingService {
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
@@ -2847,6 +2850,7 @@ export class TrainSchedulingService {
return `<tr>
${wagonCells}
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
@@ -2861,6 +2865,18 @@ export class TrainSchedulingService {
0,
);
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = item.bookingContainer?.containerSize;
if (size?.includes('40')) count40ft++;
else if (size?.includes('20')) count20ft++;
});
});
});
return `<!doctype html>
<html>
<head>
@@ -2910,6 +2926,9 @@ export class TrainSchedulingService {
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
@@ -2928,6 +2947,7 @@ export class TrainSchedulingService {
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Cargo Type</th>
<th>Company</th>
<th>Container No</th>
<th>Chassis No</th>
<th>Seal No</th>
@@ -3010,6 +3030,19 @@ export class TrainSchedulingService {
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = item.bookingContainer?.containerSize;
if (size?.includes('40')) count40ft++;
else if (size?.includes('20')) count20ft++;
});
});
});
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
@@ -3025,13 +3058,17 @@ export class TrainSchedulingService {
];
}
return wagon.allocations.map(
(allocation) => `<tr>
(allocation) => {
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
return `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
</tr>`;
},
);
})
.join('');
@@ -3096,6 +3133,9 @@ export class TrainSchedulingService {
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
@@ -3115,6 +3155,7 @@ export class TrainSchedulingService {
<th>Seq</th>
<th>Wagon</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th class="num">Weight T</th>
@@ -3971,36 +4012,12 @@ export class TrainSchedulingService {
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
stock = await this.builtTrainStock(builtTrainId);
} else {
// Dynamic consist: a slot's physical wagon may ride from the train's origin
// OR already sit at the booking's own boarding yard and attach there — so
// the usable fleet is the union across the origin and every boarding yard.
const boardYardIds = [
...new Set(
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) =>
this.countFleetAvailability(yardId, targetScheduleId),
),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
const stock: WagonStock = await this.wagonStockForSchedule(
targetScheduleId,
originYardId,
bookings.map((b) => b.originYardId),
builtTrainId,
);
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
@@ -4129,7 +4146,7 @@ export class TrainSchedulingService {
// warning (it must arrive before dispatch), but a set too weak to pull the train
// is a hard violation.
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
const setLimits = combinedLocomotiveLimits(assignedLocomotives);
if (offYard) {
warnings.push(
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
@@ -4799,6 +4816,51 @@ export class TrainSchedulingService {
* type. This is the whole plannable pool for its schedules — the plan is
* full when every consist wagon is allocated.
*/
/**
* The physical wagons a schedule can actually plan against, by wagon type.
*
* A schedule built from a Train Builder train plans against ONLY that train's
* own consist. A legacy/dynamic-consist schedule plans against the boarding
* yards' loose pool: a slot's wagon may ride from the train's origin OR
* already sit at the booking's own boarding yard and attach there, so the
* usable fleet is the union across the origin and every boarding yard.
*
* Public because batch fill needs the SAME stock the allocator will later
* validate against — selecting a booking the allocator cannot place is how
* customers ended up paying for wagons that were never there.
*/
async wagonStockForSchedule(
scheduleId: string | undefined,
originYardId: string,
boardingYardIds: Array<string | null | undefined> = [],
preloadedBuiltTrainId?: string | null,
): Promise<WagonStock> {
const builtTrainId =
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
@@ -5347,7 +5409,7 @@ export class TrainSchedulingService {
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
* philosophy: nothing serviceable is filtered out — staff see the status,
* whether the train sits at the origin yard yet, and its future schedules.
* Trains with fewer than two locomotives are omitted (never schedulable).
* Trains with no locomotive at all are omitted (never schedulable).
*/
async getAvailableTrainsForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
@@ -5385,7 +5447,7 @@ export class TrainSchedulingService {
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
return trains
.filter((train) => (train.locomotives ?? []).length >= 2)
.filter((train) => (train.locomotives ?? []).length >= 1)
.map((train) => {
const wagons = train.wagons ?? [];
return {
@@ -5417,7 +5479,15 @@ export class TrainSchedulingService {
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: roundTons(Number(train.capacityTons)),
// Live from the coupled set — `capacity_tons` still holds the old
// single-locomotive figure on trains built before pull weight summed.
maxPullWeightTons: roundTons(
combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
)?.maxPullWeightTons ?? Number(train.capacityTons),
),
atOriginYard: train.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(train.id) ?? 0,
};
@@ -5467,7 +5537,7 @@ export class TrainSchedulingService {
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
@@ -5590,7 +5660,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);

View File

@@ -0,0 +1,70 @@
import { WagonStockLedger } from './wagon-stock-ledger.util';
const WHOLE = { fromEdge: 0, toEdge: 1 };
describe('WagonStockLedger', () => {
it('reports the wagons of a booking\'s OWN types, not the train total', () => {
// The reported case: 20 free wagons on the train, but only 16 of them NW5.
const ledger = new WagonStockLedger(
new Map([
['nw5', 16],
['pw2', 4],
]),
1,
);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16);
expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4);
// A cargo type mapped to both may ride either, so they add up.
expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20);
// Duplicates must not double-count.
expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16);
// An unconfigured type has no stock.
expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0);
});
it('consumes what it can and reports the shortfall', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1);
// A 20-wagon booking can only take 16 — the caller splits on that number.
expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0);
});
it('drains the deepest stock first across candidate types', () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 10],
['nw7', 3],
]),
1,
);
expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12);
// 10 from NW5 then 2 from NW7 — one NW7 left.
expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
});
it('frees stock past an alight yard — disjoint legs never compete', () => {
// Three stops (A→B→C) = two edges. An intercity booking riding A→B must
// not consume the wagon on B→C.
const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2);
const firstLeg = { fromEdge: 0, toEdge: 1 };
const secondLeg = { fromEdge: 1, toEdge: 2 };
ledger.consume(['nw5'], 5, firstLeg);
expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0);
expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5);
// A whole-route booking sees the busiest edge it crosses, so it is blocked.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0);
});
it('counts the busiest edge within a leg, not the sum of edges', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3);
ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 });
ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 });
// Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4);
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
});
});

View File

@@ -0,0 +1,86 @@
import type { CorridorLeg } from './corridor-capacity.util';
/**
* Physical wagon-type stock for one train, consumed per corridor edge.
*
* The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight,
* length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons"
* would admit a 20-wagon booking whose cargo only rides NW5 even when the yard
* holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20,
* and allocation then failed on wagon 17 with "No NW5 wagon available at the
* yard" — money taken for space that never existed.
*
* This ledger is the missing axis: how many wagons of the types a booking may
* actually ride are free. Batch fill consults it alongside the budget, so a
* booking is admitted whole only when both agree, and is otherwise offered a
* split sized to the wagons that genuinely exist.
*
* Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at
* an alight yard is available again downstream, so an intercity ride-along on
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/
export class WagonStockLedger {
private readonly usedPerEdge = new Map<string, number[]>();
constructor(
private readonly remainingByTypeId: Map<string, number>,
private readonly edgeCount: number,
) {}
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = this.usedPerEdge.get(wagonTypeId);
if (!row) return total;
let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
busiest = Math.max(busiest, row[edge] ?? 0);
}
return Math.max(0, total - busiest);
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.
*/
availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number {
let total = 0;
for (const id of new Set(wagonTypeIds)) {
total += this.availableForType(id, leg);
}
return total;
}
/**
* Take `wagons` from the candidate types, deepest stock first so the consist
* drains evenly (same tie-break as the wagon planner). Returns how many were
* actually taken — less than asked when the stock is short.
*/
consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number {
let outstanding = Math.max(0, Math.floor(wagons));
const candidates = [...new Set(wagonTypeIds)];
let taken = 0;
while (outstanding > 0) {
const deepest = candidates
.map((id) => ({ id, free: this.availableForType(id, leg) }))
.filter((c) => c.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!deepest) break;
const take = Math.min(outstanding, deepest.free);
let row = this.usedPerEdge.get(deepest.id);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(deepest.id, row);
}
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take;
}
outstanding -= take;
taken += take;
}
return taken;
}
}

View File

@@ -6,7 +6,7 @@ import { TrainSet } from './train-set.entity';
/**
* Link row joining a train set to one of its locomotives. A train set must be
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
* pulled by at least one locomotive; `sequenceNo` is a plain
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })

View File

@@ -29,7 +29,7 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
/** All locomotives pulling this train set (minimum 1). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];

View File

@@ -33,10 +33,10 @@ export class BuildTrainDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
description: 'Locomotives pulling the train (minimum 1), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];

View File

@@ -5,10 +5,10 @@ export class UpdateTrainLocomotivesDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Full replacement locomotive set (minimum 2), in consist order',
description: 'Full replacement locomotive set (minimum 1), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
}

View File

@@ -6,7 +6,7 @@ import { Train } from './train.entity';
/**
* Link row joining a built train to one of its locomotives. A train must be
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
* pulled by at least one locomotive; `sequenceNo` is the order
* in the consist — 0 is the lead locomotive.
*
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built

View File

@@ -80,7 +80,7 @@ export class Train extends BaseEntity {
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[];
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
/** Locomotives pulling this train (minimum 1), ordered by sequenceNo. */
@OneToMany(() => TrainLocomotive, (link) => link.train)
locomotives?: TrainLocomotive[];
}

View File

@@ -62,7 +62,7 @@ export class TrainBuilderController {
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainLocomotivesDto,

View File

@@ -3,6 +3,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
@@ -10,7 +11,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -55,12 +56,14 @@ export interface ActiveScheduleRef {
*/
@Injectable()
export class TrainBuilderService {
private readonly logger = new Logger(TrainBuilderService.name);
constructor(private readonly dataSource: DataSource) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
const trainId = await this.dataSource.transaction(async (manager) => {
@@ -96,7 +99,7 @@ export class TrainBuilderService {
);
// Effective haul capacity is capped by the weakest locomotive in the set.
const limits = minLocomotiveLimits(locomotives);
const limits = combinedLocomotiveLimits(locomotives);
const train = await manager.getRepository(Train).save(
manager.getRepository(Train).create({
code,
@@ -283,7 +286,7 @@ export class TrainBuilderService {
: null,
}));
const limits = minLocomotiveLimits(
const limits = combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
@@ -339,11 +342,11 @@ export class TrainBuilderService {
};
}
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
/** Replace the locomotive set (minimum 1, same-yard rule applies). */
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
@@ -360,7 +363,7 @@ export class TrainBuilderService {
train.id,
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
const limits = minLocomotiveLimits(locomotives);
const limits = combinedLocomotiveLimits(locomotives);
await manager
.getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
@@ -520,6 +523,28 @@ export class TrainBuilderService {
sequenceNumber: null,
status: WagonStatus.Maintenance,
});
// Audit row: which train it came off and when. The wagon does not change
// yard here, so from/to are the same — the ledger is the wagon's history
// surface, and a maintenance detach has to be in it.
const yardId = wagon.currentYardId ?? train.currentYardId ?? null;
if (yardId) {
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: yardId,
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
occurredAt: new Date(),
}),
);
} else {
// to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance,
// it just cannot carry a ledger row.
this.logger.warn(
`Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`,
);
}
await this.resequenceWagons(manager, train.id);
});
return this.getComposition(id);
@@ -743,7 +768,16 @@ export class TrainBuilderService {
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: round(train.capacityTons),
// Derived live from the coupled set, NOT from the stored capacity_tons.
// That column is written at build/re-couple time, so every train built
// before pull weight became additive still holds the old single-locomotive
// figure. Computing it here keeps the board honest without a backfill;
// the column self-heals the next time the locomotive set is saved.
maxPullWeightTons: round(
combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ??
Number(train.capacityTons) ??
0,
),
};
}
@@ -878,7 +912,7 @@ export class TrainBuilderService {
where: { trainId: train.id },
relations: { locomotive: true },
});
const limits = minLocomotiveLimits(
const limits = combinedLocomotiveLimits(
links
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
export class CreateTransitAgentDto {
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
@IsString()
@MaxLength(150)
name!: string;
@ApiProperty({ example: '2026-01-01' })
@IsDateString()
validFrom!: string;
@ApiProperty({ example: '2026-12-31' })
@IsDateString()
validTo!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTransitAgentDto } from './create-transit-agent.dto';
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -0,0 +1,24 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* Djibouti transit officer GL Djibouti may assign against a shipment's
* transit-assignee handshake. Admin-managed so the roster and each officer's
* validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window.
*/
@Entity({ schema: 'freight', name: 'transit_agents' })
@Index(['isActive'])
export class TransitAgent extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'valid_from', type: 'date' })
validFrom!: string;
@Column({ name: 'valid_to', type: 'date' })
validTo!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,87 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgentsService } from './transit-agents.service';
@ApiTags('transit-agents')
@Controller('transit-agents')
@ApiBearerAuth()
export class TransitAgentsController {
constructor(private readonly transitAgentsService: TransitAgentsService) {}
@Get()
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.transitAgentsService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: undefined,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
@Get('assignable')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
findAssignable() {
return this.transitAgentsService.findAssignable();
}
@Get(':id')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'Get a transit agent by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.findById(id);
}
@Post()
@RuleEngineCreate('transit-agents')
@ApiOperation({ summary: 'Create a transit agent' })
create(@Body() dto: CreateTransitAgentDto) {
return this.transitAgentsService.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('transit-agents')
@ApiOperation({ summary: 'Update a transit agent' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
return this.transitAgentsService.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('transit-agents')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a transit agent' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsController } from './transit-agents.controller';
import { TransitAgentsRepository } from './transit-agents.repository';
import { TransitAgentsService } from './transit-agents.service';
@Module({
imports: [TypeOrmModule.forFeature([TransitAgent])],
controllers: [TransitAgentsController],
providers: [TransitAgentsRepository, TransitAgentsService],
exports: [TransitAgentsRepository, TransitAgentsService],
})
export class TransitAgentsModule {}

View File

@@ -0,0 +1,28 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
@Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
constructor(
@InjectRepository(TransitAgent)
repository: Repository<TransitAgent>,
) {
super(repository);
}
/** Active AND currently inside its validity window (today's date, server-side). */
findAssignable(today: string): Promise<TransitAgent[]> {
return this.repository.find({
where: {
isActive: true,
validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(today),
},
order: { name: 'ASC' },
});
}
}

View File

@@ -0,0 +1,138 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsRepository } from './transit-agents.repository';
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
};
type TransitAgentListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
const today = todayISODate();
if (today < agent.validFrom) return 'NOT_STARTED';
if (today > agent.validTo) return 'EXPIRED';
return 'VALID';
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { ...agent, validityStatus: validityStatus(agent) };
}
@Injectable()
export class TransitAgentsService {
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
async findAll(filter: TransitAgentListFilter = {}): Promise<{
data: TransitAgentView[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
? (filter.sortBy as keyof TransitAgent)
: 'name';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.transitAgentsRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data: data.map(withValidityStatus),
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
/** Active and currently inside its validity window — the DJ assignment dropdown. */
async findAssignable(): Promise<TransitAgent[]> {
return this.transitAgentsRepository.findAssignable(todayISODate());
}
async findById(id: string): Promise<TransitAgentView> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(agent);
}
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new BadRequestException('Selected transit officer was not found.');
}
if (!agent.isActive) {
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
}
if (validityStatus(agent) !== 'VALID') {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
}
return agent;
}
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const agent = await this.transitAgentsRepository.create({
name: dto.name.trim(),
validFrom: dto.validFrom,
validTo: dto.validTo,
isActive: dto.isActive ?? true,
});
return withValidityStatus(agent);
}
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
const current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const updated = await this.transitAgentsRepository.update(id, {
...dto,
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.transitAgentsRepository.softDelete(id);
}
}

View File

@@ -1,7 +1,17 @@
import { WagonStatus } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
export class ListWagonsQueryDto {
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
@@ -29,6 +39,15 @@ export class ListWagonsQueryDto {
@IsUUID()
trainId?: string;
@ApiPropertyOptional({
description:
'Only loose wagons (not coupled to a built train) — what a picker can actually take.',
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === true || value === 'true')
@IsBoolean()
unassigned?: boolean;
@ApiPropertyOptional({
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
})
@@ -53,11 +72,21 @@ export class ListWagonsQueryDto {
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
@Max(100)
pageSize?: number;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdTo?: string;
}

View File

@@ -232,4 +232,29 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('listRequests', () => {
// TypeORM paginates a joined query through a DISTINCT subquery and resolves
// every orderBy criterion against entity metadata — a DB column name there
// (`r.created_at`) makes it read `.databaseName` of undefined → 500.
it('sorts by the entity property path, not the DB column', async () => {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
requestRepo.createQueryBuilder.mockReturnValue(qb);
await service.listRequests({
status: 'PENDING,PARTIALLY_FULFILLED',
page: 1,
pageSize: 10,
});
expect(qb.orderBy).toHaveBeenCalledWith('r.createdAt', 'DESC');
});
});
});

View File

@@ -114,6 +114,8 @@ export class WagonTransferRequestsService {
currentYardId: yardId,
wagonTypeId,
status: WagonStatus.Available,
// Coupled to a built train = not movable; bulkTransfer rejects it too.
trainId: IsNull(),
},
});
}
@@ -159,7 +161,7 @@ export class WagonTransferRequestsService {
? 'r.quantity'
: query.sortBy === 'status'
? 'r.status'
: 'r.created_at';
: 'r.createdAt';
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
@@ -399,6 +401,7 @@ export class WagonTransferRequestsService {
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: WagonStatus.Available,
trainId: IsNull(),
},
order: { wagonNumber: 'ASC' },
take: remaining,

View File

@@ -39,7 +39,9 @@ export class WagonsController {
@Get()
@StaffReference()
@ApiOperation({ summary: 'List all wagons' })
@ApiOperation({
summary: 'List wagons, paginated ({items, meta}) — 10 per page by default',
})
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}

View File

@@ -1,4 +1,4 @@
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
Injectable,
@@ -6,7 +6,8 @@ import {
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -42,7 +43,8 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder<Wagon> {
const search = query.search?.trim();
const trainId = query.trainId?.trim();
const wagonTypeId = query.wagonTypeId?.trim();
@@ -61,6 +63,9 @@ export class WagonsService {
if (query.currentYardId)
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
// Pickers (train-builder, transfer fulfilment) can only take a wagon that is
// not already coupled to a built train — never offer one the API will reject.
if (query.unassigned) qb.andWhere('w.trainId IS NULL');
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
// Filter by run: the odd export run identifies the pair, so match either
@@ -72,6 +77,18 @@ export class WagonsService {
);
}
// Registration-day range, both ends inclusive (the UI picks whole days).
if (query.createdFrom) {
qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', {
createdFrom: query.createdFrom,
});
}
if (query.createdTo) {
qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
createdTo: query.createdTo,
});
}
// Search matches the wagon number or either run number.
if (search) {
qb.andWhere(
@@ -95,12 +112,16 @@ export class WagonsService {
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
qb.orderBy(`w.${sortBy}`, sortOrder);
if (query.page && query.limit) {
qb.skip((Number(query.page) - 1) * Number(query.limit));
}
if (query.limit) qb.take(Number(query.limit));
return qb;
}
return qb.getMany();
/**
* The wagon list is always a page. Callers that genuinely need every row
* (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice.
*/
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
}
async findById(id: string): Promise<Wagon> {

View File

@@ -3679,6 +3679,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
inspectionStatus: string | null;
}>
> {
const rows: Array<{
@@ -3696,6 +3697,7 @@ export class WarehouseInventoryService {
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
inspectionStatus: string | null;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
@@ -3710,7 +3712,8 @@ export class WarehouseInventoryService {
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
inv.inspection_status AS "inspectionStatus"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -3762,6 +3765,7 @@ export class WarehouseInventoryService {
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
inspectionStatus: r.inspectionStatus,
handoverSigned,
}));
}

View File

@@ -164,20 +164,25 @@ export class EdrOrgSeeder {
manager: EntityManager,
applicationId: string,
) {
const permissionRepository = manager.getRepository(Permission);
// Upsert by key so reruns are idempotent; applicationId ties every
// permission to the EDR Freight application (also backfills rows that
// were previously seeded without the relation).
await permissionRepository.upsert(
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
id: permission.id,
key: permission.key,
name: { ...permission.name },
applicationId,
})),
{ conflictPaths: { key: true } },
);
// iam.permissions has TWO unique columns (PK id, UQ key) but ON CONFLICT
// can only target one. Seeding a hand-minted id that some older/retired key
// already owns in an environment slips past ON CONFLICT (key) and dies on
// the PK. The key is the identity every consumer resolves by (positions
// seeder maps key -> id at runtime), so ids are left to the column default
// and never sent — no id can collide.
await manager
.createQueryBuilder()
.insert()
.into(Permission)
.values(
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
key: permission.key,
name: { ...permission.name },
applicationId,
})),
)
.orUpdate(["name", "application_id"], ["key"])
.execute();
this.logger.log(
`Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,

View File

@@ -0,0 +1,13 @@
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed';
describe('EDR_FREIGHT_PERMISSIONS', () => {
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE
// statement — a duplicated key there is a Postgres 21000 at boot, not a
// silent no-op.
it('has no duplicate keys', () => {
const keys = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key);
const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))];
expect(duplicates).toEqual([]);
});
});

View File

@@ -22,6 +22,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
// so a mid-list insert would shift ids already seeded for later slugs.
'truck-types',
'transit-agents',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -103,10 +104,17 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
// Each has its own permission so the two desks are genuinely separate people.
perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'),
perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'),
// Freeze/unfreeze a signed contract. One key covers both directions — whoever
// may suspend must be able to lift it again.
perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'),
];
// Existing per-slug view ids are kept as-is: position-type grants reference
// them by id, so re-minting would orphan those rows.
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
// lets the column default mint the uuid — so these are kept only as a record of
// which ids each environment already holds. A hand-picked id must still never
// be recycled from a retired key: `edr_freight_app:rule_engine:truck_types:manage`
// owned …001b, and reusing it for transit-agents crashed boot with a PK 23505
// on every environment that still had the retired row.
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
'container-types': 'b2000001-0001-4000-8000-000000000003',
@@ -120,6 +128,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
rates: 'b2000001-0001-4000-8000-000000000011',
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
'transit-agents': 'b2000003-0001-4000-8000-000000000001',
};
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
@@ -133,7 +142,7 @@ const ruleEngineCrudId = (
): string => {
const n =
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
1; // 1..36
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
};
@@ -447,6 +456,7 @@ export const FREIGHT_PERMS = {
clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions',
clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions',
clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise',
suspend: 'edr_freight_app:contracts:suspend',
},
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
@@ -897,6 +907,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.generateContract,
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
FREIGHT_PERMS.contracts.suspend,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;

View File

@@ -168,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",

View File

@@ -76,6 +76,10 @@ api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
}
// Tells the backend which app is asking, so /auth/login can reject
// cross-audience credentials (EDRFREIGHT-415).
config.headers["X-Client-App"] = "backoffice";
return config;
});

View File

@@ -0,0 +1,301 @@
import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { lastMileService } from "@/services/last-mile.service";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
if (!status) return { text: "Pending", color: "gray" };
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
if (status === "FAILED") return { text: "Failed", color: "red" };
return { text: status, color: "gray" };
}
interface TruckRow {
key: string;
plate: string;
driver: string | null;
truckType: string | null;
containers: string[];
warehouseArrived: string | null;
warehouseDeparted: string | null;
destinationArrived: string | null;
returned: string | null;
detentionOpen: boolean;
detentionDays: number | null;
detentionAmount: number | null;
hasDetentionRule: boolean;
inspection: { text: string; color: string };
}
/**
* Every truck tied to a booking's last mile — EDR-dispatched or customer
* self-haul (a booking only ever uses one), each with its own warehouse-gate
* and destination-detention clocks, plus the booking's cargo-side cost totals
* (storage/demurrage/double handling — billed per row internally, always
* shown here as one booking-level total). Detention stays EDR-only; customer
* self-haul rows show "—" since EDR only bills detention on its own fleet.
*/
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const [feeModalOpen, setFeeModalOpen] = useState(false);
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
);
const inventoryItems = inventoryQuery.data ?? [];
const latestInventory = inventoryItems[0] ?? null;
const edrTrucksQuery = useQuery({
queryKey: ["booking-edr-trucks", bookingId],
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
});
const edrTrucks = edrTrucksQuery.data ?? [];
const customerTrucksQuery = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
});
const customerTrucks = customerTrucksQuery.data ?? [];
const mode: "EDR" | "CUSTOMER" | "NONE" =
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
const containerItemsQuery = useQuery({
queryKey: ["booking-container-items-for-trucks", bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId),
});
const inspectionByContainer = new Map(
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
);
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
const detentionPreviewQuery = useQuery({
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const detentionPreview = detentionPreviewQuery.data;
const detentionByVehicle = new Map(
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
);
const lastMileRecordQuery = useQuery({
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
// Booking-level cost strip: same per-row fee preview the accrual dashboard
// and FeePreviewModal already use, summed across every inventory row on
// this booking rather than duplicated per row.
const feeQueries = useQueries({
queries: inventoryItems.map((item) =>
api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }),
),
});
const allFees = feeQueries.flatMap((q) => q.data ?? []);
const feeCurrency = allFees[0]?.currency ?? "USD";
const sumByType = (type: string) =>
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
const rows: TruckRow[] = useMemo(() => {
if (mode === "EDR") {
return edrTrucks.map((t) => {
const g = detentionByVehicle.get(t.vehicleId);
return {
key: t.vehicleId,
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
driver: t.driverName,
truckType: t.truckType,
containers: t.containerNumber ? [t.containerNumber] : [],
warehouseArrived: t.arrivedAt,
warehouseDeparted: t.departedAt,
destinationArrived: g?.startDate ?? null,
returned: g?.endIsOpen ? null : g?.endDate ?? null,
detentionOpen: Boolean(g?.endIsOpen),
detentionDays: g?.chargeableDays ?? null,
detentionAmount: g?.amount ?? null,
hasDetentionRule: Boolean(g?.ruleId),
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
};
});
}
if (mode === "CUSTOMER") {
return customerTrucks.map((t) => {
const containers = (t.containers ?? []).map((c) => c.containerNumber);
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
const inspection =
containers.length === 0
? inspectionLabel(undefined)
: statuses.size > 1
? { text: "Mixed", color: "yellow" }
: inspectionLabel([...statuses][0]);
return {
key: t.id,
plate: t.plateNumber,
driver: t.driverName,
truckType: t.truckType,
containers,
warehouseArrived: t.arrivedAt ?? null,
warehouseDeparted: t.departedAt ?? null,
destinationArrived: null,
returned: null,
detentionOpen: false,
detentionDays: null,
detentionAmount: null,
hasDetentionRule: false,
inspection,
};
});
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
return (
<Center py={60}>
<Group gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading trucks</Text>
</Group>
</Center>
);
}
return (
<Stack gap="lg">
<SectionCard
icon={Coins}
title="Cargo costs"
subtitle="Storage, demurrage & double handling — booking total"
accent="teal"
extra={
latestInventory && (
<Button size="xs" variant="light" onClick={() => setFeeModalOpen(true)}>
View breakdown
</Button>
)
}
>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile label="Storage" value={money(sumByType("STORAGE_FEE"), feeCurrency)} />
<MetricTile label="Demurrage" value={money(sumByType("DEMURRAGE_FEE"), feeCurrency)} />
<MetricTile label="Double handling" value={money(sumByType("DOUBLE_HANDLING_FEE"), feeCurrency)} />
</SimpleGrid>
</SectionCard>
<SectionCard
icon={Truck}
title="Trucks"
subtitle={
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
}
accent="grape"
extra={
mode === "EDR" && (
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
Detention times
</Button>
)
}
>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No trucks assigned to this booking's last mile yet.
</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Container(s)</Table.Th>
<Table.Th>Wh. arrived</Table.Th>
<Table.Th>Wh. departed</Table.Th>
<Table.Th>Dest. arrived</Table.Th>
<Table.Th>Returned</Table.Th>
<Table.Th>Detention</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.key}>
<Table.Td>{r.plate}</Table.Td>
<Table.Td>{r.driver ?? "—"}</Table.Td>
<Table.Td>{r.truckType ?? "—"}</Table.Td>
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
<Table.Td>
{r.detentionOpen ? (
<Badge size="xs" color="orange" variant="light">
still out
</Badge>
) : (
fmt(r.returned)
)}
</Table.Td>
<Table.Td>
{mode !== "EDR" || r.detentionDays == null ? (
"—"
) : (
<>
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
{!r.hasDetentionRule && (
<Text span size="xs" c="red">
{" "}
· no rule
</Text>
)}
</>
)}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={r.inspection.color}>
{r.inspection.text}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</SectionCard>
<FeePreviewModal
opened={feeModalOpen}
onClose={() => setFeeModalOpen(false)}
inventoryId={latestInventory?.id ?? null}
/>
{mode === "EDR" && (
<TruckDetentionModal
opened={detentionModalOpen}
onClose={() => setDetentionModalOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
)}
</Stack>
);
}

View File

@@ -2,6 +2,7 @@ export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./BookingTrucksPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
bookingId: string | undefined;
@@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps {
/** Phased customs workflow files — enables the Uploaded documents tab. */
workflowFiles?: Freight.ClearanceWorkflowFile[];
showWorkflowFilesTab?: boolean;
/**
* Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange
* belongs on this page. Undefined hides the tab; it is also hidden from staff
* who hold neither desk's clearance-actions permission.
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
@@ -40,6 +49,7 @@ export function ClearanceOpsTabs({
clearanceTab,
workflowFiles = [],
showWorkflowFilesTab = false,
exchangeEntityId,
tradeDirection = "IMPORT",
onViewFile,
onDownloadFile,
@@ -53,7 +63,12 @@ export function ClearanceOpsTabs({
return true;
}).length;
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
const { user } = useAuth();
const showExchange =
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
return <>{clearanceTab}</>;
@@ -78,6 +93,11 @@ export function ClearanceOpsTabs({
Uploaded documents
</Tabs.Tab>
) : null}
{showExchange ? (
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -103,6 +123,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showExchange ? (
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={exchangeEntityId!} />
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record<
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
Eye,
FilePen,
// FilePen, // ponytail: back with the "Edit contract articles" button
FileSignature,
MessageSquareWarning,
PauseCircle,
PlayCircle,
ShieldCheck,
XCircle,
Zap,
@@ -43,6 +45,21 @@ const CLEARANCE_REVIEW_STATUSES = [
"CLEARANCE_READY_FOR_BOOKING",
];
/**
* Every step from the customer signature onward can be frozen. Mirrors
* SUSPENDABLE_CONTRACT_STATUSES on the API — the server is the authority, this
* list only decides whether the button is drawn.
*/
const SUSPENDABLE_STATUSES = [
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({
contract,
@@ -62,6 +79,8 @@ export function ContractActionsToolbar({
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
// One key both ways — whoever can freeze a contract can unfreeze it.
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -70,6 +89,10 @@ export function ContractActionsToolbar({
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [suspendOpen, setSuspendOpen] = useState(false);
const [suspendReason, setSuspendReason] = useState("");
const [resumeOpen, setResumeOpen] = useState(false);
const [resumeNote, setResumeNote] = useState("");
// Whether the document is editable depends on WHO is viewing — only the
// approver whose turn it is may edit — so the server decides, not the client.
@@ -110,6 +133,86 @@ export function ContractActionsToolbar({
);
}
// Frozen: nothing on this contract moves — no new bookings, no progress on
// the shipments already under it — until the suspension is lifted, which
// returns the contract to the status it was suspended at.
if (status === "SUSPENDED") {
return (
<SectionCard icon={PauseCircle} title="Contract suspended">
<Stack gap="sm">
<Text size="sm" c="dimmed">
This contract is frozen. New bookings are blocked and its existing
shipments cannot progress.
{contract.statusBeforeSuspension
? ` Lifting the suspension returns it to ${contract.statusBeforeSuspension}.`
: ""}
</Text>
{contract.latestSuspensionNote && (
<Text size="sm">
<b>Reason:</b> {contract.latestSuspensionNote}
</Text>
)}
{maySuspend ? (
<Button
fullWidth
color="edr-green"
leftSection={<PlayCircle size={16} />}
onClick={() => setResumeOpen(true)}
>
Lift suspension
</Button>
) : (
<Text size="sm" c="dimmed">
You do not have permission to lift a suspension.
</Text>
)}
</Stack>
<Modal
opened={resumeOpen}
onClose={() => setResumeOpen(false)}
title="Lift suspension?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will return to{" "}
<b>{contract.statusBeforeSuspension ?? "CONTRACT_ACTIVE"}</b> and
the customer will be notified. Bookings on it resume immediately.
</Text>
<Textarea
label="Note (optional)"
placeholder="Why the suspension is being lifted…"
autosize
minRows={2}
value={resumeNote}
onChange={(e) => setResumeNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setResumeOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={mutations.resume.isPending}
onClick={() =>
mutations.resume.mutate(resumeNote.trim() || undefined, {
onSuccess: () => {
setResumeOpen(false);
setResumeNote("");
},
})
}
>
Lift suspension
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// The document stays editable for the whole approval chain, but only by the
@@ -130,6 +233,7 @@ export function ContractActionsToolbar({
const clearanceReviewer = contract.customsClearingEnabled
? "Review clearance (GL)"
: "Review clearance (Ops)";
const canSuspend = maySuspend && SUSPENDABLE_STATUSES.includes(status);
return (
<SectionCard icon={Zap} title="Staff actions">
@@ -182,9 +286,9 @@ export function ContractActionsToolbar({
<>
<Text size="xs" c="dimmed">
{canEditDocument
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
? "It is your turn to approve — the PDF is generated automatically once the last approver approves."
: draft?.nextApproverRole
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
? `Awaiting ${draft.nextApproverRole}.`
: "Awaiting approval."}
</Text>
<Button
@@ -196,6 +300,9 @@ export function ContractActionsToolbar({
>
Preview document
</Button>
{/* Article editing is hidden for now (frontend only) — the approval
chain approves the document as accepted. Uncomment to restore.
{canEditDocument && (
<Button
fullWidth
@@ -210,6 +317,8 @@ export function ContractActionsToolbar({
Edit contract articles
</Button>
)}
*/}
</>
)}
@@ -241,10 +350,23 @@ export function ContractActionsToolbar({
{/* GL "Create booking" removed for now — clearance ends at finalize and
the customer creates the booking in the portal. */}
{canSuspend && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<PauseCircle size={16} />}
onClick={() => setSuspendOpen(true)}
>
Suspend contract
</Button>
)}
{!canAccept &&
!inApproval &&
!canViewContract &&
!canReviewClearance && (
!canReviewClearance &&
!canSuspend && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the
workflow advances.
@@ -315,6 +437,51 @@ export function ContractActionsToolbar({
</Stack>
</Modal>
{/* Suspend — freezes the contract AND every shipment under it */}
<Modal
opened={suspendOpen}
onClose={() => setSuspendOpen(false)}
title="Suspend this contract?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will be frozen at its current
step (<b>{status}</b>). No new shipments can be booked and the
shipments already under it stop moving until the suspension is
lifted. The customer is notified.
</Text>
<Textarea
label="Reason for suspension"
placeholder="Explain why this contract is being suspended…"
autosize
minRows={3}
value={suspendReason}
onChange={(e) => setSuspendReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSuspendOpen(false)}>
Cancel
</Button>
<Button
color="orange"
disabled={!suspendReason.trim()}
loading={mutations.suspend.isPending}
onClick={() =>
mutations.suspend.mutate(suspendReason, {
onSuccess: () => {
setSuspendOpen(false);
setSuspendReason("");
},
})
}
>
Suspend contract
</Button>
</Group>
</Stack>
</Modal>
{/* Reject */}
<Modal
opened={rejectOpen}

View File

@@ -31,6 +31,17 @@ const isHazardStep = (requiredRole: string): boolean =>
const roleLabel = (requiredRole: string): string =>
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
/** When the approver acted — "27 Jul 2026, 18:18". */
const fmtActedAt = (iso: string): string =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractApprovalStepsCardProps {
@@ -349,6 +360,10 @@ function StepRow({
? "edr-green"
: "gray";
const hazard = isHazardStep(step.requiredRole);
// A send-back wipes acted_at with the status, so a re-opened step shows no
// stale timestamp.
const acted =
step.actedAt && step.status !== "PENDING" ? fmtActedAt(step.actedAt) : null;
return (
<Group
@@ -412,6 +427,14 @@ function StepRow({
</Badge>
)}
</Group>
{/* Decided steps carry their verdict time — the chain doubles as an
audit trail, so "who was waiting on whom, and for how long" has to
be readable without opening the revision history. */}
{acted && (
<Text size="xs" c="dimmed" truncate>
{step.status === "REJECTED" ? "Rejected" : "Approved"} {acted}
</Text>
)}
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}

Some files were not shown because too many files have changed in this diff Show More