mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTransitAgentDto } from './create-transit-agent.dto';
|
||||
|
||||
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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}'`,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user