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";
|
||||
@@ -101,6 +102,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: [
|
||||
@@ -240,6 +242,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
LoginAudienceMiddleware,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -328,5 +331,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,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. */
|
||||
@@ -362,6 +392,21 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disputed the advised duty & tax. This goes to STAFF, not the
|
||||
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
|
||||
* page is where they do it.
|
||||
*/
|
||||
dutyDisputed(b: Booking, note: string): void {
|
||||
const msg =
|
||||
`The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||
this.inAppStaff(b, `Duty disputed on ${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 names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
async assignBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@@ -872,6 +900,24 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty/dispute')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||
})
|
||||
async disputeBookingDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.disputeDuty(
|
||||
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 disputed the advised duty & tax and asked GL Ethiopia to
|
||||
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||
*/
|
||||
'DUTY_DISPUTE',
|
||||
] 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,6 +101,8 @@ function makeService(overrides?: {
|
||||
documentQueried: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
} as never, // notifier
|
||||
);
|
||||
|
||||
@@ -186,6 +198,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', () => {
|
||||
|
||||
@@ -71,12 +71,32 @@ 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;
|
||||
/**
|
||||
* The customer's open objection to the advised duty. Present only until GL
|
||||
* re-advises; `rounds` counts how many times it has been sent back.
|
||||
*/
|
||||
dutyDispute?: {
|
||||
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;
|
||||
@@ -113,13 +133,10 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
) {}
|
||||
|
||||
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 +144,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 +231,7 @@ export class BookingClearanceService {
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const dutyDispute = await this.buildDutyDispute(bookingId, milestones);
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
@@ -272,7 +290,18 @@ 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,
|
||||
dutyDispute,
|
||||
workflowFiles,
|
||||
t1,
|
||||
train,
|
||||
@@ -330,6 +359,22 @@ export class BookingClearanceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildDutyDispute(
|
||||
bookingId: string,
|
||||
milestones: ClearanceMilestone[],
|
||||
): Promise<BookingClearanceView['dutyDispute']> {
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||
if (!advised || advised.status === 'COMPLETED') return null;
|
||||
const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE');
|
||||
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 +397,60 @@ 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 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.
|
||||
*/
|
||||
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
if (!booking.transitAssigneeRequestedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
|
||||
);
|
||||
}
|
||||
|
||||
const previous = booking.transitAssigneeName ?? null;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
@@ -373,6 +464,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') {
|
||||
@@ -460,6 +561,65 @@ export class BookingClearanceService {
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disagrees with the advised duty & tax on this booking and asks
|
||||
* GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so
|
||||
* the Duty & tax step becomes actionable again on the GL clearance page, with
|
||||
* the customer's message shown beside it. GL re-advises (same endpoint as the
|
||||
* first time), which closes the dispute — the loop may run as many rounds as
|
||||
* it takes.
|
||||
*/
|
||||
async disputeDuty(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty applies only to import bookings.');
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'Say what is wrong with the advised amount so GL can correct it.',
|
||||
);
|
||||
}
|
||||
if (!booking.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'There is no advised duty amount to dispute yet.',
|
||||
);
|
||||
}
|
||||
// Once the slip is in, the money is paid — a dispute then is a refund
|
||||
// conversation, not a re-advice.
|
||||
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note.trim(),
|
||||
'DUTY_DISPUTE',
|
||||
userId,
|
||||
);
|
||||
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
|
||||
// (the stepper picks its active step from milestone completion).
|
||||
await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED');
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.dutyDisputed(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') {
|
||||
@@ -720,7 +880,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 +893,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, {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The duty advice → dispute → re-advice loop, at the booking level. GL
|
||||
* Ethiopia advises an amount; the customer either pays it or sends it back
|
||||
* with a reason. Sending it back reopens the advice milestone — that is what
|
||||
* puts the Duty & tax step back in GL's hands — and the round can repeat
|
||||
* until the amount is agreed.
|
||||
*/
|
||||
describe('BookingClearanceService — duty dispute', () => {
|
||||
const booking = (over: Partial<Booking> = {}): Booking =>
|
||||
({
|
||||
id: 'bk-1',
|
||||
reference: 'BKG-2026-00042',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractId: 'ctr-1',
|
||||
dutyRequired: true,
|
||||
...over,
|
||||
}) as Booking;
|
||||
|
||||
const milestone = (code: string, status: string) =>
|
||||
({ milestoneCode: code, status }) as never;
|
||||
|
||||
let repo: {
|
||||
createReviewNote: jest.Mock;
|
||||
findReviewNotes: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let bookingsService: { findById: jest.Mock };
|
||||
let workflowService: { listMilestonesForBooking: jest.Mock };
|
||||
let milestoneService: { reopenForBooking: jest.Mock };
|
||||
let notifier: { dutyDisputed: jest.Mock };
|
||||
let service: BookingClearanceService;
|
||||
|
||||
const build = (milestones: unknown[]) => {
|
||||
workflowService.listMilestonesForBooking.mockResolvedValue(milestones);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
bookingsService = { findById: jest.fn().mockResolvedValue(booking()) };
|
||||
workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) };
|
||||
milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) };
|
||||
notifier = { dutyDisputed: jest.fn() };
|
||||
|
||||
service = new BookingClearanceService(
|
||||
repo as never,
|
||||
bookingsService as never,
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
{} as never, // dropdownSettingsService
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
);
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
milestone('DUTY_TAX_PAID', 'PENDING'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('records the objection and hands the step back to GL', async () => {
|
||||
await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1');
|
||||
|
||||
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||
'bk-1',
|
||||
'Declared value is wrong',
|
||||
'DUTY_DISPUTE',
|
||||
'user-1',
|
||||
);
|
||||
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||
expect(milestoneService.reopenForBooking).toHaveBeenCalledWith(
|
||||
'bk-1',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
expect(repo.update).toHaveBeenCalledWith('bk-1', {
|
||||
clearanceCurrentPhase: 'GL_ET_OUTPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('tells GL Ethiopia, not the customer', async () => {
|
||||
await service.disputeDuty('bk-1', 'Too high', 'user-1');
|
||||
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'bk-1' }),
|
||||
'Too high',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||
await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(milestoneService.reopenForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when nothing has been advised yet', async () => {
|
||||
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||
/no advised duty amount/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
milestone('DUTY_TAX_PAID', 'COMPLETED'),
|
||||
]);
|
||||
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||
/already been submitted/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses when duty was never required for this clearance', async () => {
|
||||
bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false }));
|
||||
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||
/not required/i,
|
||||
);
|
||||
});
|
||||
|
||||
describe('the view', () => {
|
||||
const buildDispute = (milestones: unknown[]) =>
|
||||
(
|
||||
service as unknown as {
|
||||
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
).buildDutyDispute('bk-1', milestones);
|
||||
|
||||
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
|
||||
]);
|
||||
|
||||
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
|
||||
});
|
||||
|
||||
it('clears itself once GL re-advises', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
]);
|
||||
|
||||
expect(dispute).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,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';
|
||||
|
||||
@@ -1060,46 +1060,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> {
|
||||
@@ -1693,80 +1653,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);
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
@@ -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,
|
||||
@@ -1335,7 +1386,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
OverageTolerance,
|
||||
stopYardsFor,
|
||||
} from './corridor-capacity.util';
|
||||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||
|
||||
export type { Capacity } from './corridor-capacity.util';
|
||||
|
||||
@@ -1619,6 +1620,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");
|
||||
@@ -1655,14 +1658,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 +1685,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 +1715,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,11 +1836,14 @@ 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;
|
||||
}> = [];
|
||||
@@ -1844,7 +1860,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);
|
||||
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 };
|
||||
|
||||
@@ -1884,12 +1901,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 +1959,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 +1995,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 +2061,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 +2099,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;
|
||||
}
|
||||
@@ -3468,6 +3521,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
|
||||
@@ -3971,36 +3971,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 +4105,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 +4775,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 +5368,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 +5406,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 +5438,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 +5496,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 +5619,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)),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -103,6 +103,9 @@ 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
|
||||
@@ -447,6 +450,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 +901,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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -9,23 +9,24 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
// Select, // ponytail: unused now the validity dropdown below is commented out
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
// Tooltip, // ponytail: back with the article editor block
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
// ArrowDown, // ponytail: back with the article editor block
|
||||
// ArrowUp,
|
||||
FileText,
|
||||
Info,
|
||||
Lock,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
@@ -37,6 +38,30 @@ function newArticleId(): string {
|
||||
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
/** Midnight today — the earliest day a contract's validity may start. */
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Local `YYYY-MM-DD` — the shape Mantine hands day cells. */
|
||||
function localDay(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print today in bold inside the calendar. `highlightToday` only rings the cell,
|
||||
* which staff read as "disabled" on a picker whose minimum IS today — the weight
|
||||
* makes it obvious the day is pickable.
|
||||
*/
|
||||
function boldToday(date: string) {
|
||||
return date === localDay(new Date())
|
||||
? { style: { fontWeight: 800 } }
|
||||
: {};
|
||||
}
|
||||
|
||||
interface EditableArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -118,6 +143,17 @@ export function ContractDocumentEditorModal({
|
||||
);
|
||||
}, [opened, draft]);
|
||||
|
||||
// Accept mode opens on NOW — a contract never starts in the past, and the
|
||||
// pickers below refuse earlier days. Seconds are dropped so the value matches
|
||||
// what the HH:mm picker shows.
|
||||
useEffect(() => {
|
||||
if (!opened || mode !== "accept") return;
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
setValidityStart(now);
|
||||
setValidityEnd(null);
|
||||
}, [opened, mode]);
|
||||
|
||||
// Default validity to the first configured option (accept mode).
|
||||
// useEffect(() => {
|
||||
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||
@@ -129,29 +165,30 @@ export function ContractDocumentEditorModal({
|
||||
// decides per-caller — the client cannot derive this from the contract alone.
|
||||
const locked = mode === "edit" && !draft?.editableByMe;
|
||||
|
||||
const moveArticle = (index: number, delta: number) => {
|
||||
setArticles((prev) => {
|
||||
const next = [...prev];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Article edit handlers — parked with the editor block below.
|
||||
// const moveArticle = (index: number, delta: number) => {
|
||||
// setArticles((prev) => {
|
||||
// const next = [...prev];
|
||||
// const target = index + delta;
|
||||
// if (target < 0 || target >= next.length) return prev;
|
||||
// [next[index], next[target]] = [next[target], next[index]];
|
||||
// return next;
|
||||
// });
|
||||
// };
|
||||
|
||||
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
||||
setArticles((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
||||
);
|
||||
// const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
||||
// setArticles((prev) =>
|
||||
// prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
||||
// );
|
||||
|
||||
const removeArticle = (id: string) =>
|
||||
setArticles((prev) => prev.filter((a) => a.id !== id));
|
||||
// const removeArticle = (id: string) =>
|
||||
// setArticles((prev) => prev.filter((a) => a.id !== id));
|
||||
|
||||
const addArticle = () =>
|
||||
setArticles((prev) => [
|
||||
...prev,
|
||||
{ id: newArticleId(), title: "", body: "" },
|
||||
]);
|
||||
// const addArticle = () =>
|
||||
// setArticles((prev) => [
|
||||
// ...prev,
|
||||
// { id: newArticleId(), title: "", body: "" },
|
||||
// ]);
|
||||
|
||||
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
|
||||
code: draft?.code ?? null,
|
||||
@@ -238,17 +275,63 @@ export function ContractDocumentEditorModal({
|
||||
? draft?.nextApproverRole
|
||||
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
|
||||
: "This document can no longer be edited — the contract has advanced beyond approval."
|
||||
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
||||
: "This document is read-only — it is accepted exactly as the template produced it. Set the validity dates, then accept."}
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label="Document title"
|
||||
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
|
||||
value={documentTitle}
|
||||
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
|
||||
disabled={locked}
|
||||
/>
|
||||
{/* Accept is a REVIEW step: the document is shown exactly as the
|
||||
template produced it, with nothing editable. Any wording change
|
||||
belongs to the template or to the separate edit action. */}
|
||||
{mode === "accept" ? (
|
||||
<ScrollArea.Autosize mah={340} type="auto">
|
||||
<Stack gap="sm" pr="sm">
|
||||
<Text fw={700} fz={15}>
|
||||
{documentTitle || "Contract document"}
|
||||
</Text>
|
||||
|
||||
{whereasClauses.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{whereasClauses.map((clause, i) => (
|
||||
<Text key={i} fz={13} c="dimmed">
|
||||
WHEREAS {clause}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<Text fz={13} c="dimmed">
|
||||
This template carries no articles.
|
||||
</Text>
|
||||
) : (
|
||||
articles.map((article, index) => (
|
||||
<Box key={article.id}>
|
||||
<Text fz={13} fw={700}>
|
||||
Article {index + 1}
|
||||
{article.title ? ` — ${article.title}` : ""}
|
||||
</Text>
|
||||
<Text
|
||||
fz={12.5}
|
||||
c="dimmed"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{article.body}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Document title"
|
||||
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
|
||||
value={documentTitle}
|
||||
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
|
||||
disabled={locked}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode !== "accept" && (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -304,6 +387,12 @@ export function ContractDocumentEditorModal({
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Article editing is hidden for now (frontend only) — staff accept the
|
||||
contract on the template's articles as-is. The articles themselves
|
||||
still ride along in buildSnapshot(), so the generated document is
|
||||
unchanged. Uncomment this block to bring the editor back.
|
||||
|
||||
<Divider label="Articles" labelPosition="left" />
|
||||
|
||||
@@ -364,7 +453,7 @@ export function ContractDocumentEditorModal({
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
|
||||
placeholder="Article body — each line becomes a numbered clause."
|
||||
autosize
|
||||
minRows={3}
|
||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||
@@ -389,6 +478,8 @@ export function ContractDocumentEditorModal({
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
*/}
|
||||
|
||||
<Divider />
|
||||
|
||||
{mode === "accept" && (
|
||||
@@ -414,20 +505,29 @@ export function ContractDocumentEditorModal({
|
||||
</Text>
|
||||
)} */}
|
||||
<Group grow align="flex-start">
|
||||
<DateInput
|
||||
label="Start date"
|
||||
<DateTimePicker
|
||||
label="Start date & time"
|
||||
placeholder="Contract validity start"
|
||||
value={validityStart}
|
||||
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
|
||||
// Today is the earliest start — and it is ringed in the
|
||||
// calendar so it reads as selectable rather than blocked.
|
||||
minDate={startOfToday()}
|
||||
maxDate={validityEnd ?? undefined}
|
||||
highlightToday
|
||||
getDayProps={boldToday}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable
|
||||
/>
|
||||
<DateInput
|
||||
label="End date"
|
||||
<DateTimePicker
|
||||
label="End date & time"
|
||||
placeholder="Contract validity end"
|
||||
value={validityEnd}
|
||||
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
|
||||
minDate={validityStart ?? undefined}
|
||||
minDate={validityStart ?? startOfToday()}
|
||||
highlightToday
|
||||
getDayProps={boldToday}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BadgeCheck,
|
||||
CalendarClock,
|
||||
Flame,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
FileSignature,
|
||||
} from "lucide-react";
|
||||
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
interface ContractMilestonesTimelineProps {
|
||||
contract: Freight.IContract;
|
||||
}
|
||||
|
||||
const SIGNATURE_ROLE_LABELS: Record<Freight.ContractSignatureRole, string> = {
|
||||
CUSTOMER: "Signed by customer",
|
||||
STAFF: "Signed by EDR — line staff",
|
||||
DIRECTOR: "Signed by EDR — director",
|
||||
CEO: "Signed by EDR — CEO",
|
||||
};
|
||||
|
||||
/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */
|
||||
function formatWhen(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** "3 hours ago" — the at-a-glance read. */
|
||||
function formatAgo(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||
for (const [unit, secondsPerUnit] of units) {
|
||||
if (seconds >= secondsPerUnit) {
|
||||
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
|
||||
}
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
}
|
||||
|
||||
type MilestoneIcon = typeof Send;
|
||||
|
||||
interface Milestone {
|
||||
key: string;
|
||||
at: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
color: string;
|
||||
icon: MilestoneIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dated moments of a contract's life — submission, hazardous approval,
|
||||
* final approval, both parties' signatures, full execution — read straight off
|
||||
* the contract and its already-loaded approvalSteps/signatures (no extra
|
||||
* fetch). Sits above the document edit history on the History tab.
|
||||
*/
|
||||
export function ContractMilestonesTimeline({
|
||||
contract,
|
||||
}: ContractMilestonesTimelineProps) {
|
||||
const milestones = useMemo<Milestone[]>(() => {
|
||||
const items: Milestone[] = [];
|
||||
|
||||
// A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing
|
||||
// to date. submittedAt is only tracked going forward; a contract that
|
||||
// reached SUBMITTED before that column existed falls back to createdAt.
|
||||
const submittedAt =
|
||||
contract.submittedAt ??
|
||||
(contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT"
|
||||
? contract.createdAt
|
||||
: null);
|
||||
if (submittedAt) {
|
||||
items.push({
|
||||
key: "submitted",
|
||||
at: submittedAt,
|
||||
title: "Submitted for review",
|
||||
color: "blue",
|
||||
icon: Send,
|
||||
});
|
||||
}
|
||||
|
||||
for (const step of contract.approvalSteps ?? []) {
|
||||
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
|
||||
if (!step.actedAt) continue;
|
||||
items.push({
|
||||
key: `hazard-${step.id}`,
|
||||
at: step.actedAt,
|
||||
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
|
||||
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
|
||||
color: step.status === "REJECTED" ? "red" : "orange",
|
||||
icon: Flame,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.contractGeneratedAt) {
|
||||
items.push({
|
||||
key: "approved",
|
||||
at: contract.contractGeneratedAt,
|
||||
title: "Contract approved",
|
||||
detail: "Every approval step cleared and the document was generated",
|
||||
color: "edr-green",
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sig of contract.signatures ?? []) {
|
||||
items.push({
|
||||
key: `signature-${sig.id}`,
|
||||
at: sig.signedAt,
|
||||
title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`,
|
||||
detail: sig.signerDisplayName,
|
||||
color: "grape",
|
||||
icon: FileSignature,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.fullyExecutedAt) {
|
||||
items.push({
|
||||
key: "executed",
|
||||
at: contract.fullyExecutedAt,
|
||||
title: "Fully executed",
|
||||
detail: "Both parties have signed",
|
||||
color: "edr-green",
|
||||
icon: BadgeCheck,
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort(
|
||||
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
|
||||
);
|
||||
}, [contract]);
|
||||
|
||||
const hasValidity = contract.contractValidFrom && contract.contractValidUntil;
|
||||
|
||||
if (milestones.length === 0 && !hasValidity) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No dated milestones recorded yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{hasValidity && (
|
||||
<Group
|
||||
gap="xs"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<CalendarClock size={16} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="sm">
|
||||
Valid <strong>{formatDate(contract.contractValidFrom!)}</strong>
|
||||
{" → "}
|
||||
<strong>{formatDate(contract.contractValidUntil!)}</strong>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{milestones.length > 0 && (
|
||||
<Timeline active={milestones.length} bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{milestones.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={m.key}
|
||||
bullet={<Icon size={13} />}
|
||||
color={m.color}
|
||||
title={
|
||||
<Group gap="xs" wrap="wrap" align="baseline">
|
||||
<Text size="sm" fw={600}>
|
||||
{m.title}
|
||||
</Text>
|
||||
<Tooltip label={formatWhen(m.at)} withArrow>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatAgo(m.at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{m.detail && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{m.detail}
|
||||
</Text>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1018,14 +1018,9 @@ export default function GlCreateBookingForm() {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
if (contract.contractKind === "GENERAL") {
|
||||
// GENERAL per-booking clearance: land on the booking's clearance
|
||||
// detail — the same page the Shipments tab on the hub opens.
|
||||
navigate(`/dashboard/clearance/${booking.id}`);
|
||||
} else {
|
||||
// ONE_TIME customs keeps its clearance on the contract.
|
||||
navigate(`/dashboard/contracts/clearance/${contract.id}`);
|
||||
}
|
||||
// Clearance is always per booking — land on that booking's clearance
|
||||
// detail, the same page the hub opens.
|
||||
navigate(`/dashboard/clearance/${booking.id}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1067,7 +1062,13 @@ export default function GlCreateBookingForm() {
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => navigate(`/dashboard/contracts/clearance/${contract.id}`)}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
completeBookingId
|
||||
? `/dashboard/clearance/${completeBookingId}`
|
||||
: "/dashboard/contracts/clearance",
|
||||
)
|
||||
}
|
||||
>
|
||||
Back to clearance
|
||||
</Button>
|
||||
|
||||
@@ -349,11 +349,11 @@ export function PhasedClearanceActionPanel({
|
||||
API refuses the upload until the name is in. */}
|
||||
{showEt &&
|
||||
canEt &&
|
||||
!isBooking &&
|
||||
!clearance.transitAssignee?.name &&
|
||||
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
|
||||
<TransitAssigneePanel
|
||||
contractId={entityId}
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
|
||||
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
|
||||
import { RefreshCw, Stamp, X } from "lucide-react";
|
||||
|
||||
const MAX_STAMP_MB = 5;
|
||||
const MAX_STAMP_MB = 10;
|
||||
|
||||
export interface StampUploadProps {
|
||||
/** Stamp image as a data URL, or null when none is attached yet. */
|
||||
|
||||
@@ -15,10 +15,14 @@ import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
export interface TransitAssigneePanelProps {
|
||||
contractId: string;
|
||||
/** Booking id when `isBooking`, contract id otherwise. */
|
||||
entityId: string;
|
||||
/** Clearance runs per booking now; contract-level cycles are the legacy case. */
|
||||
isBooking?: boolean;
|
||||
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
|
||||
/**
|
||||
* ET asks and waits; DJ answers with a name. The same state renders from both
|
||||
@@ -50,7 +54,8 @@ const fmt = (iso?: string | null) =>
|
||||
* different name later; the newest one wins and Ethiopia is notified again.
|
||||
*/
|
||||
export function TransitAssigneePanel({
|
||||
contractId,
|
||||
entityId,
|
||||
isBooking = false,
|
||||
transitAssignee,
|
||||
side,
|
||||
readOnly = false,
|
||||
@@ -59,9 +64,12 @@ export function TransitAssigneePanel({
|
||||
const [note, setNote] = useState("");
|
||||
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
||||
const [changing, setChanging] = useState(false);
|
||||
const service = isBooking ? bookingsService : contractsService;
|
||||
|
||||
const request = useMutation({
|
||||
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
|
||||
mutationFn: async () => {
|
||||
await service.requestTransitAssignee(entityId, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Request sent to GL Djibouti");
|
||||
setNote("");
|
||||
@@ -70,8 +78,9 @@ export function TransitAssigneePanel({
|
||||
});
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.assignTransitAssignee(contractId, assignee.trim()),
|
||||
mutationFn: async () => {
|
||||
await service.assignTransitAssignee(entityId, assignee.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Transit assignee sent to GL Ethiopia");
|
||||
setChanging(false);
|
||||
|
||||
@@ -95,12 +95,16 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
|
||||
);
|
||||
}
|
||||
|
||||
// Validity is accepted to the minute, so the expiry reads with its time — a
|
||||
// contract that lapses at 09:00 looks identical to one lapsing at 23:59 without it.
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ const KIND_META: Record<string, { label: string; color: string; icon: ReactNode
|
||||
color: "orange",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
MAINTENANCE: {
|
||||
label: "Sent to maintenance",
|
||||
color: "red",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
const yardLabel = (
|
||||
@@ -103,10 +108,16 @@ const WagonMovementHistoryModal = ({
|
||||
<Text size="sm" fw={600}>
|
||||
{from}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{to}
|
||||
</Text>
|
||||
{/* Status events (maintenance) sit in one yard — an arrow
|
||||
pointing at the same yard reads as a broken row. */}
|
||||
{movement.fromYardId !== movement.toYardId && (
|
||||
<>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
@@ -41,7 +41,12 @@ export default function AvailableWagonsPanel({
|
||||
const wagonsQuery = useQuery(
|
||||
api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: { status: Freight.WagonStatus.Available, currentYardId: yardId },
|
||||
filters: {
|
||||
status: Freight.WagonStatus.Available,
|
||||
currentYardId: yardId,
|
||||
// Loose wagons only — one already on another train cannot be coupled.
|
||||
unassigned: true,
|
||||
},
|
||||
},
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,7 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
|
||||
/**
|
||||
* Step one of the Train Builder: pick the yard it is being assembled in and
|
||||
* couple at least two locomotives from that yard. The train code is assigned by
|
||||
* couple at least one locomotive from that yard. The train code is assigned by
|
||||
* the system. Wagons are attached afterwards on the composition page.
|
||||
*/
|
||||
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
|
||||
@@ -86,9 +86,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
}, [opened]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (!yardId || locomotiveIds.length < 2) {
|
||||
if (!yardId || locomotiveIds.length < 1) {
|
||||
toast({
|
||||
title: "Pick a yard and couple at least two locomotives",
|
||||
title: "Pick a yard and couple at least one locomotive",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -185,17 +185,15 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
|
||||
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
|
||||
description="A train must be pulled by at least one locomotive. First pick becomes the lead."
|
||||
placeholder={yardId ? "Select at least one locomotive" : "Select a yard first"}
|
||||
data={locomotiveOptions}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
disabled={!yardId}
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
||||
}
|
||||
nothingFoundMessage={
|
||||
yardId ? "No available locomotives in this yard" : "Select a yard first"
|
||||
|
||||
@@ -16,7 +16,7 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
|
||||
/** Swap the locomotive set of a built train (minimum 1, same-yard rule). */
|
||||
export default function ChangeLocomotivesModal({
|
||||
composition,
|
||||
opened,
|
||||
@@ -74,8 +74,8 @@ export default function ChangeLocomotivesModal({
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition) return;
|
||||
if (locomotiveIds.length < 2) {
|
||||
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
|
||||
if (locomotiveIds.length < 1) {
|
||||
toast({ title: "A train needs at least one locomotive", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -112,9 +112,7 @@ export default function ChangeLocomotivesModal({
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
||||
}
|
||||
nothingFoundMessage="No available locomotives in this yard"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
import { wagonTypeColor } from "./trainStatus";
|
||||
|
||||
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
|
||||
const PortalAwareRow = ({
|
||||
@@ -58,11 +59,36 @@ export default function ConsistWagonList({
|
||||
);
|
||||
}
|
||||
|
||||
// Legend of the types actually coupled, in consist order — the colour code is
|
||||
// only readable if the row tints are keyed somewhere.
|
||||
const legend = [
|
||||
...new Map(
|
||||
wagons
|
||||
.filter((w) => w.wagonType)
|
||||
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||
{(dropProvided) => (
|
||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{legend.length > 1 ? (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{legend.map((type) => (
|
||||
<Badge
|
||||
key={type.code}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagonTypeColor(type.code)}
|
||||
>
|
||||
{type.code} · {type.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
@@ -97,8 +123,8 @@ export interface ConsistWagonListProps {
|
||||
editable: boolean;
|
||||
onReorder: (wagonIds: string[]) => void;
|
||||
onRemove: (wagonId: string) => void;
|
||||
/** Detach the wagon and move it to MAINTENANCE status. */
|
||||
onMaintenance: (wagonId: string) => void;
|
||||
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
@@ -119,8 +145,10 @@ function WagonRow({
|
||||
editable: boolean;
|
||||
busy: boolean;
|
||||
onRemove: (wagonId: string) => void;
|
||||
onMaintenance: (wagonId: string) => void;
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
}) {
|
||||
const color = wagonTypeColor(wagon.wagonType?.code);
|
||||
|
||||
return (
|
||||
<PortalAwareRow snapshot={snapshot}>
|
||||
<Group
|
||||
@@ -132,9 +160,14 @@ function WagonRow({
|
||||
p="sm"
|
||||
style={{
|
||||
...dragProvided.draggableProps.style,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
border: `1px solid var(--mantine-color-${color}-2)`,
|
||||
borderLeft: `4px solid var(--mantine-color-${color}-5)`,
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
||||
// Tinted by wagon type so a mixed consist is scannable at a glance;
|
||||
// the drag state keeps its own neutral lift.
|
||||
background: snapshot.isDragging
|
||||
? "white"
|
||||
: `var(--mantine-color-${color}-0)`,
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
||||
userSelect: "none",
|
||||
@@ -145,13 +178,20 @@ function WagonRow({
|
||||
<GripVertical size={18} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
<Badge variant="filled" color={color} size="sm">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
{wagon.wagonType ? (
|
||||
<Badge variant="light" color={color} size="xs" radius="sm">
|
||||
{wagon.wagonType.code}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||
@@ -165,7 +205,7 @@ function WagonRow({
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
disabled={busy}
|
||||
onClick={() => onMaintenance(wagon.id)}
|
||||
onClick={() => onMaintenance(wagon)}
|
||||
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
|
||||
>
|
||||
<Wrench size={16} />
|
||||
|
||||
@@ -56,6 +56,58 @@ export const locomotiveStatusLabel = (status: string): string =>
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
/**
|
||||
* Hues for the wagon-type color code. No red or gray — red reads as a fault on
|
||||
* a consist row, gray is the "unknown type" fallback.
|
||||
*/
|
||||
const WAGON_TYPE_COLORS = [
|
||||
"blue",
|
||||
"teal",
|
||||
"grape",
|
||||
"orange",
|
||||
"cyan",
|
||||
"indigo",
|
||||
"pink",
|
||||
"lime",
|
||||
"violet",
|
||||
"yellow",
|
||||
];
|
||||
|
||||
/**
|
||||
* Fixed hue per seeded wagon-type code. Related families sit on neighbouring
|
||||
* hues (gondolas blue/indigo, hoppers grape/violet, flats teal/lime) so a
|
||||
* consist reads as groups, not confetti. Explicit rather than hashed because
|
||||
* hashing 10 codes into 10 hues collides — and two types sharing a colour is
|
||||
* exactly what a colour code must not do.
|
||||
*/
|
||||
const WAGON_TYPE_CODE_COLORS: Record<string, string> = {
|
||||
CW3: "blue", // Gondola open
|
||||
CW4: "indigo", // Gondola covered
|
||||
KW2: "grape", // Hopper covered
|
||||
KW3: "violet", // Hopper open
|
||||
NW5: "teal", // Flat
|
||||
NW6: "lime", // Flat (long)
|
||||
NW7: "pink", // Double deck sedan
|
||||
BW1: "cyan", // Refrigerated
|
||||
GW2: "orange", // Tank
|
||||
PW2: "yellow", // Box
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable hue per wagon-type code. Unseeded codes fall back to a hash so a new
|
||||
* type still gets a consistent colour instead of collapsing to gray.
|
||||
*/
|
||||
export const wagonTypeColor = (code?: string | null): string => {
|
||||
if (!code) return "gray";
|
||||
const seeded = WAGON_TYPE_CODE_COLORS[code];
|
||||
if (seeded) return seeded;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
hash = (hash * 31 + code.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return WAGON_TYPE_COLORS[hash % WAGON_TYPE_COLORS.length]!;
|
||||
};
|
||||
|
||||
/** Badge color per trade direction (Mantine palette keys). */
|
||||
export const directionColor = (direction?: string | null): string =>
|
||||
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
|
||||
|
||||
@@ -144,6 +144,10 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_DECLARATION: (id: string) =>
|
||||
`/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
||||
`/bookings/${id}/clearance/transit-assignee/request`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
||||
`/bookings/${id}/clearance/transit-assignee/assign`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||
@@ -170,6 +174,8 @@ export const URL_CONSTANTS = {
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/contracts/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
REJECT_STEP: (id: string, stepId: string) =>
|
||||
@@ -217,10 +223,7 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/${id}/clearance/export-release`,
|
||||
CLEARANCE_FINALIZE_EXPORT: (id: string) =>
|
||||
`/contracts/${id}/clearance/finalize-export-clearance`,
|
||||
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
|
||||
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
||||
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||
`/contracts/${id}/clearance/ops-review`,
|
||||
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
||||
@@ -228,6 +231,9 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||
BOOKINGS_INITIATE: (id: string) => `/contracts/${id}/bookings/initiate`,
|
||||
// GL worklist: executed customs contracts with no shipment instance yet.
|
||||
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
|
||||
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
||||
`/contracts/${id}/bookings/${bookingId}/complete`,
|
||||
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
||||
|
||||
@@ -67,8 +67,14 @@ export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Shipment in Progress",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
SUSPENDED: {
|
||||
label: "Suspended",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
CONTRACT_CLOSED: {
|
||||
label: "Closed",
|
||||
// A fulfilled contract (one-time shipment delivered, or cap consumed) —
|
||||
// greyed out to read as inactive.
|
||||
label: "Completed",
|
||||
color: "bg-slate-100 text-slate-700 border-slate-300",
|
||||
},
|
||||
EXPIRED: {
|
||||
@@ -122,6 +128,7 @@ export const CONTRACT_STATUS_COLOR: Record<string, string> = {
|
||||
CLEARANCE_UNDER_REVIEW: "yellow",
|
||||
CLEARANCE_READY_FOR_BOOKING: "edr-green",
|
||||
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
|
||||
SUSPENDED: "orange",
|
||||
CONTRACT_CLOSED: "gray",
|
||||
EXPIRED: "red",
|
||||
REJECTED: "red",
|
||||
@@ -232,11 +239,17 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
|
||||
stage: 4,
|
||||
},
|
||||
CONTRACT_CLOSED: {
|
||||
title: "Closed",
|
||||
description: "Contract fulfilled and closed.",
|
||||
title: "Completed",
|
||||
description: "Contract fulfilled — its shipment was delivered.",
|
||||
color: "text-slate-500",
|
||||
stage: 5,
|
||||
},
|
||||
SUSPENDED: {
|
||||
title: "Suspended",
|
||||
description: "Frozen by EDR — bookings and shipments are on hold.",
|
||||
color: "text-orange-600",
|
||||
stage: -1,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Expired",
|
||||
description: "Validity window elapsed.",
|
||||
|
||||
@@ -53,30 +53,9 @@ export function useContractClearanceQueue(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useEtClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("ET"),
|
||||
queryFn: () => contractsService.getEtClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDjClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("DJ"),
|
||||
queryFn: () => contractsService.getDjClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||
export function useOpsClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
|
||||
queryFn: () => contractsService.getOpsClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
// The awaiting-shipment worklist hook was removed with the clearance hub's
|
||||
// "Start shipment" dialog — nothing calls GET /contracts/awaiting-shipment any
|
||||
// more. The endpoint still exists server-side if the worklist comes back.
|
||||
|
||||
export function useContractClearanceHistory(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -172,6 +151,19 @@ export function useContractMutations(contractId: string) {
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
||||
});
|
||||
|
||||
const suspend = useMutation({
|
||||
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to suspend contract")),
|
||||
});
|
||||
|
||||
const resume = useMutation({
|
||||
mutationFn: (note: string | undefined) => contractsService.resume(contractId, note),
|
||||
onSuccess: (data) =>
|
||||
onSuccess(data, `Suspension lifted — contract is back to ${data.status}`),
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
// The server derives the required role from the step itself, so the client
|
||||
// does not send one.
|
||||
@@ -277,6 +269,8 @@ export function useContractMutations(contractId: string) {
|
||||
updateDocument,
|
||||
requestChanges,
|
||||
reject,
|
||||
suspend,
|
||||
resume,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
|
||||
@@ -60,6 +60,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -50,9 +50,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||
const id = params.id ?? params.bookingId;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
// The same shipment is opened from several worklists (GL Ethiopia clearance,
|
||||
// the Operations clearance-documents hub, shipment requests…), so "back" is
|
||||
// whichever list sent us here. Deep links have no sender: fall back to the
|
||||
// hub this user actually works in.
|
||||
const backTo =
|
||||
(location.state as { from?: string } | null)?.from ??
|
||||
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
? "/dashboard/contracts/clearance"
|
||||
: "/dashboard/contracts/clearance-documents");
|
||||
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
@@ -95,10 +106,10 @@ export default function DocumentClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = booking?.reference ?? "Clearance";
|
||||
// Phased customs clearance runs on every contract booking now — ONE_TIME and
|
||||
// GENERAL alike; the persisted phase is what marks the workflow as running.
|
||||
const isPhasedGeneral =
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
Boolean(booking?.customsClearingEnabled) && Boolean(clearance?.phase);
|
||||
|
||||
// Bare initiated instance whose clearance is done: GL completes the booking
|
||||
// (container numbers, VGM, shipment day) via the completion form.
|
||||
@@ -147,9 +158,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: "Document Clearance", href: backTo },
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
@@ -165,9 +176,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: "Document Clearance", href: backTo },
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -139,6 +139,7 @@ export default function DocumentClearanceListPage({
|
||||
opsMode?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -205,8 +206,13 @@ export default function DocumentClearanceListPage({
|
||||
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/clearance/${id}`),
|
||||
[navigate],
|
||||
// `from` so the detail page's Back returns to this list, whichever route
|
||||
// it is mounted at (ops self-clearance review, history, …).
|
||||
(id: string) =>
|
||||
navigate(`/dashboard/clearance/${id}`, {
|
||||
state: { from: location.pathname },
|
||||
}),
|
||||
[navigate, location.pathname],
|
||||
);
|
||||
|
||||
const statusBadge = isHistory ? (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -6,36 +5,20 @@ import {
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
toContractListRow,
|
||||
type ContractListRow,
|
||||
} from "@/features/contracts/mapContractListRow";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
Badge,
|
||||
@@ -46,45 +29,16 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Operations "Clearance Documents" hub — worklist for clearance-document
|
||||
* review on contracts WITHOUT customs clearing (self-clearance):
|
||||
* Contracts tab = contract-level review (one-time flow), General tab =
|
||||
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
|
||||
* the existing review detail pages; search / status filter / pagination are
|
||||
* all server-side.
|
||||
* Operations "Clearance Documents" hub — the worklist for self-clearance
|
||||
* (non-customs) document review. Clearance is always per SHIPMENT: the customer
|
||||
* uploads his documents on the booking he initiated, whatever kind of contract
|
||||
* it draws on, so this hub lists bookings only. Rows deep-link to the booking
|
||||
* clearance review page; search / status filter / pagination are server-side.
|
||||
*/
|
||||
|
||||
type HubTab = "contracts" | "general";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Status filter options for the Contracts tab (values = `statuses` param). */
|
||||
const CONTRACT_STATUS_OPTIONS = [
|
||||
{
|
||||
value: [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"CONTRACT_CLOSED",
|
||||
"CANCELLED",
|
||||
].join(","),
|
||||
label: "All statuses",
|
||||
},
|
||||
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
|
||||
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
|
||||
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
|
||||
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
|
||||
{
|
||||
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
|
||||
label: "In progress / closed",
|
||||
},
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
/** Status filter options for the General (per-booking) tab. */
|
||||
/** Status filter options (values = `statuses` param). */
|
||||
const BOOKING_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
|
||||
@@ -97,12 +51,8 @@ const BOOKING_STATUS_OPTIONS = [
|
||||
|
||||
export default function ClearanceDocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [contractStatuses, setContractStatuses] = useState(
|
||||
CONTRACT_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
const [bookingStatuses, setBookingStatuses] = useState(
|
||||
BOOKING_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
@@ -116,34 +66,12 @@ export default function ClearanceDocumentsPage() {
|
||||
|
||||
const page = pagination.pageIndex + 1;
|
||||
|
||||
const contractsQuery = useQuery({
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"contracts",
|
||||
contractStatuses,
|
||||
page,
|
||||
search,
|
||||
],
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
|
||||
queryFn: () =>
|
||||
contractsService.getOpsClearanceQueue({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
statuses: contractStatuses,
|
||||
search,
|
||||
}),
|
||||
enabled: hubTab === "contracts",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const generalQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
|
||||
queryFn: () =>
|
||||
// Per-booking self-clearance instances are drawdowns under GENERAL
|
||||
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
|
||||
// is one-time) with contractKind=GENERAL, so filtering on
|
||||
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
|
||||
// =false + the three per-booking clearance statuses already isolate
|
||||
// exactly this worklist — the same set the old booking-request tab showed.
|
||||
// Self-clearance instances carry bookingType=ONE_TIME whatever their
|
||||
// contract kind, so customsClearingEnabled=false + the three per-booking
|
||||
// clearance statuses are what isolate exactly this worklist.
|
||||
bookingsService.list({
|
||||
statuses: bookingStatuses,
|
||||
customsClearingEnabled: "false",
|
||||
@@ -151,112 +79,10 @@ export default function ClearanceDocumentsPage() {
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
}),
|
||||
enabled: hubTab === "general",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const contractRows = useMemo(
|
||||
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
|
||||
[contractsQuery.data?.items],
|
||||
);
|
||||
const bookingRows = generalQuery.data?.items ?? [];
|
||||
|
||||
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{c.reference}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{c.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||
>
|
||||
{directionLabel(c.tradeDirection)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||
>
|
||||
{c.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
|
||||
>
|
||||
{row.original.contractKind === "GENERAL" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3" /> General
|
||||
</span>
|
||||
) : (
|
||||
"One-time"
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<ContractStatusBadge
|
||||
status={row.original.status}
|
||||
isRenewal={row.original.isRenewal}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
const bookingRows = bookingsQuery.data?.items ?? [];
|
||||
|
||||
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
|
||||
() => [
|
||||
@@ -335,39 +161,29 @@ export default function ClearanceDocumentsPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const isContracts = hubTab === "contracts";
|
||||
const activeQuery = isContracts ? contractsQuery : generalQuery;
|
||||
const total = activeQuery.data?.total ?? 0;
|
||||
const total = bookingsQuery.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const showEmpty =
|
||||
!activeQuery.isLoading &&
|
||||
!activeQuery.isError &&
|
||||
(isContracts ? contractRows.length : bookingRows.length) === 0;
|
||||
const tableStatus = activeQuery.isLoading
|
||||
!bookingsQuery.isLoading && !bookingsQuery.isError && bookingRows.length === 0;
|
||||
const tableStatus = bookingsQuery.isLoading
|
||||
? "loading"
|
||||
: activeQuery.isError
|
||||
: bookingsQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
const statusOptions = isContracts
|
||||
? CONTRACT_STATUS_OPTIONS
|
||||
: BOOKING_STATUS_OPTIONS;
|
||||
const statusValue = isContracts ? contractStatuses : bookingStatuses;
|
||||
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Clearance Documents"
|
||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
|
||||
subtitle="Operations review of the clearance documents customers upload on their shipments (services without customs clearing)."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
loading={activeQuery.isFetching}
|
||||
onClick={() => void activeQuery.refetch()}
|
||||
loading={bookingsQuery.isFetching}
|
||||
onClick={() => void bookingsQuery.refetch()}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
@@ -375,29 +191,12 @@ export default function ClearanceDocumentsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={hubTab}
|
||||
onChange={(v) => {
|
||||
setHubTab((v as HubTab) ?? "contracts");
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder={
|
||||
isContracts
|
||||
? "Search reference or customer…"
|
||||
: "Search booking, contract or customer…"
|
||||
}
|
||||
placeholder="Search booking, contract or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
@@ -424,10 +223,10 @@ export default function ClearanceDocumentsPage() {
|
||||
radius="lg"
|
||||
/>
|
||||
<Select
|
||||
data={statusOptions}
|
||||
value={statusValue}
|
||||
data={BOOKING_STATUS_OPTIONS}
|
||||
value={bookingStatuses}
|
||||
onChange={(v) => {
|
||||
setStatusValue(v ?? statusOptions[0].value);
|
||||
setBookingStatuses(v ?? BOOKING_STATUS_OPTIONS[0].value);
|
||||
resetPage();
|
||||
}}
|
||||
allowDeselect={false}
|
||||
@@ -446,61 +245,36 @@ export default function ClearanceDocumentsPage() {
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">
|
||||
No {isContracts ? "contracts" : "bookings"} match this view.
|
||||
</Text>
|
||||
<Text c="dimmed">No shipments match this view.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
{isContracts ? (
|
||||
<DataTable
|
||||
columns={contractColumns}
|
||||
data={contractRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/clearance-documents/${row.id}`,
|
||||
)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={bookingColumns}
|
||||
data={bookingRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/clearance/${row.id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
columns={bookingColumns}
|
||||
data={bookingRows}
|
||||
status={tableStatus}
|
||||
// `from` so the detail page's Back returns to THIS hub, not
|
||||
// to whichever worklist the fallback would guess.
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/clearance/${row.id}`, {
|
||||
state: { from: "/dashboard/contracts/clearance-documents" },
|
||||
})
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -16,7 +8,6 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -26,38 +17,32 @@ import {
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
@@ -67,99 +52,6 @@ import {
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "shipments";
|
||||
|
||||
/** Persist the selected queue tab so returning from a detail keeps it. */
|
||||
const QUEUE_TAB_STORAGE_KEY = "edr.clearance.queueTab";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
|
||||
routeStops: string[];
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
/** true once GL has finalized clearance — customer may book in the portal. */
|
||||
ready: boolean;
|
||||
/** true once GL Ethiopia created the shipment booking. */
|
||||
bookingCreated: boolean;
|
||||
/** true when the created booking EXPIRED unpaid — GL must rebook. */
|
||||
paymentExpired: boolean;
|
||||
/** The expired booking, so rebook can copy its cargo. */
|
||||
expiredBookingId: string | null;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain the contract's ordered route legs into one corridor of stops —
|
||||
* origin of the first leg, then each leg's destination (Djibouti → Adama →
|
||||
* Dire Dawa). A leg whose origin differs from the previous destination inserts
|
||||
* that stop too, so gapped route lists stay readable.
|
||||
*/
|
||||
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
|
||||
const stops: string[] = [];
|
||||
for (const r of routes) {
|
||||
const origin = yardLabel(r.originYard);
|
||||
const destination = yardLabel(r.destinationYard);
|
||||
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
|
||||
stops.push(origin);
|
||||
}
|
||||
stops.push(destination);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
// The queue joins the company relation — show its name, never the raw uuid.
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.company?.name ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
routeStops: contractRouteStops(routes),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
paymentExpired:
|
||||
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
|
||||
contract.latestCycleBookingStatus === "EXPIRED",
|
||||
expiredBookingId:
|
||||
contract.latestCycleBookingStatus === "EXPIRED"
|
||||
? (contract.latestCycleBookingId ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
return customs ? (
|
||||
@@ -179,199 +71,32 @@ function CustomsBadge({ customs }: { customs: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const label = directionLabel(direction);
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
// Terminal contracts stay listed as history — badge the terminal state
|
||||
// instead of falling through to "Under review".
|
||||
if (["EXPIRED", "CANCELLED", "REJECTED"].includes(row.status)) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="This contract is no longer active — kept here for clearance history."
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={row.status === "EXPIRED" ? "orange" : "red"}
|
||||
radius="sm"
|
||||
>
|
||||
{row.status === "EXPIRED"
|
||||
? "Contract expired"
|
||||
: row.status === "CANCELLED"
|
||||
? "Cancelled"
|
||||
: "Rejected"}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.paymentExpired) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<RefreshCw size={12} />}
|
||||
>
|
||||
Payment expired
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.bookingCreated) {
|
||||
return (
|
||||
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackagePlus size={12} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.ready) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="Document approval finalized — the customer creates the booking in the portal"
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={12} />}
|
||||
>
|
||||
Documents approved
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created — stays visible for reference and follow-up.
|
||||
* Document Clearance hub (GL Ethiopia). Clearance always runs on the SHIPMENT:
|
||||
* every row here is a booking instance in phased customs clearance, whatever
|
||||
* kind of contract it draws on. The "Start shipment" dialog (and its
|
||||
* awaiting-shipment contract list) was removed — shipments are opened from the
|
||||
* contract itself, not from this hub.
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
// Creating a booking under a cleared contract is a GL Ethiopia action — never
|
||||
// Opening/creating a booking under a contract is a GL Ethiopia action — never
|
||||
// available to Djibouti GL.
|
||||
const canCreateBooking =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "shipments";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(() => {
|
||||
const stored =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem(QUEUE_TAB_STORAGE_KEY)
|
||||
: null;
|
||||
return stored === "all" || stored === "shipments" ? stored : defaultQueue;
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const selectQueueTab = useCallback((tab: QueueTab) => {
|
||||
setQueueTab(tab);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(QUEUE_TAB_STORAGE_KEY, tab);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Contract clearance rows feed both the Contracts tab and the header KPIs, so
|
||||
// they load regardless of the active tab.
|
||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||
useContractClearanceQueue(true);
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading: bookingsLoading,
|
||||
isError: bookingsError,
|
||||
isFetching: bookingsFetching,
|
||||
refetch: refetchBookings,
|
||||
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||
|
||||
const data = allData;
|
||||
const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
|
||||
const isError = queueTab === "shipments" ? bookingsError : allError;
|
||||
const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "shipments") void refetchBookings();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
const queueTabOptions = useMemo(() => {
|
||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||
if (canReview) {
|
||||
opts.push({
|
||||
value: "all",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={15} />
|
||||
<Box visibleFrom="sm">Contracts</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canReview || canEt) {
|
||||
opts.push({
|
||||
value: "shipments",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<PackageCheck size={15} />
|
||||
<Box visibleFrom="sm">Shipments</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt]);
|
||||
|
||||
// If a persisted/default tab isn't available for this user, fall back to the
|
||||
// first permitted tab.
|
||||
useEffect(() => {
|
||||
if (
|
||||
queueTabOptions.length > 0 &&
|
||||
!queueTabOptions.some((o) => o.value === queueTab)
|
||||
) {
|
||||
selectQueueTab(queueTabOptions[0].value);
|
||||
}
|
||||
}, [queueTabOptions, queueTab, selectQueueTab]);
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useBookingEtClearanceQueue(true);
|
||||
|
||||
// Shipment requests carry the requested quantities (per container type, or
|
||||
// bulk weight/items). Map them onto the booking rows by createdBookingId so
|
||||
@@ -379,7 +104,6 @@ export default function ContractClearanceListPage() {
|
||||
const { data: requestQueue } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
queryFn: () => contractsService.getBookingRequestQueue(),
|
||||
enabled: queueTab === "shipments",
|
||||
});
|
||||
const requestedByBooking = useMemo(() => {
|
||||
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||
@@ -389,9 +113,8 @@ export default function ContractClearanceListPage() {
|
||||
return map;
|
||||
}, [requestQueue]);
|
||||
|
||||
// GENERAL-contract shipment bookings in per-booking clearance.
|
||||
const bookingRows = useMemo(() => {
|
||||
const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
const allRows = useMemo(() => {
|
||||
return (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
@@ -406,37 +129,11 @@ export default function ContractClearanceListPage() {
|
||||
contractKind: b.contractKind ?? null,
|
||||
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||
createdAt: b.createdAt ?? null,
|
||||
// A bare initiated instance has no cargo/price yet — GL still has to create
|
||||
// (complete) the booking.
|
||||
// A bare initiated instance has no cargo/price yet — GL still has to
|
||||
// create (complete) the booking.
|
||||
bookingCreated: Number(b.totalAmount ?? 0) > 0,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
);
|
||||
}, [bookingQueue, query, requestedByBooking]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
[data?.items],
|
||||
);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
ready: allRows.filter((r) => r.ready).length,
|
||||
booked: allRows.filter((r) => r.bookingCreated).length,
|
||||
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
})) as ShipmentBookingRow[];
|
||||
}, [bookingQueue, requestedByBooking]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -445,163 +142,33 @@ export default function ContractClearanceListPage() {
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const total = queueTab === "shipments" ? bookingRows.length : rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return rows.slice(start, start + pagination.pageSize);
|
||||
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
|
||||
[navigate],
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
review: allRows.filter(
|
||||
(r) => r.status === "AWAITING_DOCUMENTS" || r.status === "DOCUMENTS_UNDER_REVIEW",
|
||||
).length,
|
||||
ready: allRows.filter((r) => r.status === "CLEARANCE_READY" || r.bookingCreated)
|
||||
.length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{(r.routeStops.length >= 2
|
||||
? r.routeStops
|
||||
: [r.originLabel, r.destinationLabel]
|
||||
).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<Text size="sm" fw={500}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{r.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
header: () => <span className={bookingTable.headerCell}>Service</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate maw={160}>
|
||||
{r.serviceTypeName}
|
||||
</Text>
|
||||
<CustomsBadge customs={r.customs} />
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => <StatusBadge row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 150,
|
||||
cell: ({ row }) =>
|
||||
row.original.ready && canCreateBooking ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.original.id}/create-booking`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : row.original.paymentExpired && canCreateBooking ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="grape"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.original.id}/create-booking${
|
||||
row.original.expiredBookingId
|
||||
? `?copyFrom=${row.original.expiredBookingId}`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate, canCreateBooking],
|
||||
|
||||
const openBooking = useCallback(
|
||||
// `from` so the detail page's Back returns to this hub.
|
||||
(id: string) =>
|
||||
navigate(`/dashboard/clearance/${id}`, {
|
||||
state: { from: "/dashboard/contracts/clearance" },
|
||||
}),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -609,7 +176,7 @@ export default function ContractClearanceListPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
|
||||
subtitle="Every customs shipment in phased clearance — the documents live on the shipment, not on the contract."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -621,16 +188,18 @@ export default function ContractClearanceListPage() {
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -651,7 +220,7 @@ export default function ContractClearanceListPage() {
|
||||
},
|
||||
{
|
||||
label: "Ready / booked",
|
||||
value: counts.ready + counts.booked,
|
||||
value: counts.ready,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -662,32 +231,15 @@ export default function ContractClearanceListPage() {
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
{queueTabOptions.length > 1 ? (
|
||||
<Box px="md" pt="md">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={queueTab}
|
||||
onChange={(v) => {
|
||||
selectQueueTab(v as QueueTab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={queueTabOptions}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, or route…"
|
||||
placeholder="Search shipment, contract, customer or route…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
@@ -705,100 +257,39 @@ export default function ContractClearanceListPage() {
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as ViewMode)}
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<TableIcon size={15} />
|
||||
<Box visibleFrom="sm">Table</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<LayoutGrid size={15} />
|
||||
<Box visibleFrom="sm">Cards</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{rows.length} record{rows.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{queueTab === "shipments" ? (
|
||||
<ShipmentBookingsTable
|
||||
rows={bookingRows}
|
||||
loading={bookingsLoading}
|
||||
error={bookingsError}
|
||||
canCreateBooking={canCreateBooking}
|
||||
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
|
||||
onCreateBooking={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
// Re-complete the SAME expired booking (new day, same finished
|
||||
// per-booking clearance) — a fresh create-booking would spawn a
|
||||
// new instance and force the customer through clearance + fee
|
||||
// again.
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||
}
|
||||
/>
|
||||
) : view === "table" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
<ShipmentBookingsTable
|
||||
rows={rows}
|
||||
loading={isLoading}
|
||||
error={isError}
|
||||
canCreateBooking={canCreateBooking}
|
||||
onOpen={openBooking}
|
||||
onCreateBooking={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
// Re-complete the SAME expired booking (new day, same finished
|
||||
// per-booking clearance) — a fresh instance would force the
|
||||
// customer through clearance + fee again.
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/${contractId}`)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1124,135 +615,3 @@ function ShipmentBookingsTable({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box px="md" py="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Stack align="center" gap={8} py={48}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No contracts need customs clearance.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
pb="md"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "var(--mantine-spacing-md)",
|
||||
}}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p="md"
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer", transition: "all 120ms ease" }}
|
||||
className="hover:border-edr-green-4 hover:shadow-md"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="edr-text" truncate>
|
||||
{row.reference}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={11} className="shrink-0 opacity-70" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{row.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<StatusBadge row={row} />
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
mt="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-edr-card-6)",
|
||||
border: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" justify="center">
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" mt="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<DirectionIcon direction={row.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{row.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" mt={8} wrap="nowrap" gap={8}>
|
||||
<Text size="xs" c="dimmed" truncate maw={150}>
|
||||
{row.serviceTypeName}
|
||||
</Text>
|
||||
<CustomsBadge customs={row.customs} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Flame,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
@@ -54,6 +55,7 @@ import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprov
|
||||
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
@@ -110,6 +112,21 @@ function formatDate(value: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Same, plus the clock — for values the staff pick to the minute. */
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -370,7 +387,8 @@ export default function ContractRequestDetailPage() {
|
||||
{contract.contractValidUntil ? (
|
||||
<MetaItem
|
||||
icon={CalendarClock}
|
||||
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
|
||||
// Validity is accepted to the minute — show the time.
|
||||
text={`Valid until ${formatDateTime(contract.contractValidUntil)}`}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -515,13 +533,22 @@ export default function ContractRequestDetailPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "history" ? (
|
||||
<SectionCard
|
||||
icon={History}
|
||||
title="Change history"
|
||||
subtitle="Every recorded edit to this contract — who changed what, and when."
|
||||
>
|
||||
<ContractRevisionTimeline contractId={contract.id} bare />
|
||||
</SectionCard>
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Milestone}
|
||||
title="Key milestones"
|
||||
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
|
||||
>
|
||||
<ContractMilestonesTimeline contract={contract} />
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
icon={History}
|
||||
title="Change history"
|
||||
subtitle="Every recorded edit to this contract — who changed what, and when."
|
||||
>
|
||||
<ContractRevisionTimeline contractId={contract.id} bare />
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
|
||||
@@ -236,13 +236,15 @@ export default function GlClearanceDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
{/* GL Ethiopia cannot file the customs declaration until this desk
|
||||
names the officer handling the shipment in transit, so the ask
|
||||
sits above everything else on the page. */}
|
||||
{data.kind === "contract" ? (
|
||||
{/* GL Ethiopia cannot file the import customs declaration until this
|
||||
desk names the officer handling the shipment in transit, so the
|
||||
ask sits above everything else on the page. Exports have no such
|
||||
gate — Djibouti's steps come after the declaration. */}
|
||||
{isImport ? (
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
contractId={id!}
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -21,14 +20,11 @@ import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
ShipWheel,
|
||||
Truck,
|
||||
User,
|
||||
@@ -41,18 +37,14 @@ import {
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
type QueueTab = "contracts" | "shipments";
|
||||
|
||||
const prettyStatus = (s?: string | null) =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
@@ -198,52 +190,6 @@ function toShipmentRow(b: BookingDetail): ShipmentRow {
|
||||
};
|
||||
}
|
||||
|
||||
interface ContractRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
clearanceStatus: string;
|
||||
phase: string | null;
|
||||
cycleNumber: number;
|
||||
validFrom: string | null;
|
||||
validUntil: string | null;
|
||||
validityDays: number | null;
|
||||
estimatedShipmentDate: string | null;
|
||||
}
|
||||
|
||||
function toContractRow(c: Freight.IContract): ContractRow {
|
||||
const routes = [...(c.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: c.id,
|
||||
reference: c.reference,
|
||||
customerLabel: c.isGovernment
|
||||
? (c.governmentInstitution ?? "Government")
|
||||
: (c.company?.name ?? "—"),
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
tradeDirection: c.tradeDirection ?? "—",
|
||||
freightType: c.freightType ?? "—",
|
||||
serviceTypeName: c.serviceType?.serviceName ?? "—",
|
||||
customs: c.serviceType?.includesCustoms ?? Boolean(c.customsClearingEnabled),
|
||||
status: c.status,
|
||||
clearanceStatus: c.clearanceStatus,
|
||||
phase: (c.clearancePhase as string | null) ?? null,
|
||||
cycleNumber: c.clearanceCycleNumber ?? 1,
|
||||
validFrom: c.contractValidFrom ?? null,
|
||||
validUntil: c.contractValidUntil ?? null,
|
||||
validityDays: c.contractValidityDays ?? null,
|
||||
estimatedShipmentDate: c.estimatedShipmentDate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Shared cell pieces ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -301,15 +247,13 @@ function RouteCell({
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GL Djibouti clearance queues:
|
||||
* - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow).
|
||||
* - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ
|
||||
* action (DO collection after ET finalizes pre-clearance, RO for exports,
|
||||
* loading milestones). Managed like the one-time flow, but per booking.
|
||||
* GL Djibouti clearance queue. Clearance runs per SHIPMENT — every booking in
|
||||
* per-booking clearance awaiting a Djibouti action (DO collection after ET
|
||||
* finalizes pre-clearance, RO for exports, loading milestones), whatever kind
|
||||
* of contract it draws on.
|
||||
*/
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<QueueTab>("shipments");
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string | null>(null);
|
||||
const [freight, setFreight] = useState<string | null>(null);
|
||||
@@ -317,13 +261,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const {
|
||||
data: contractQueue,
|
||||
isLoading: contractsLoading,
|
||||
isError: contractsError,
|
||||
isFetching: contractsFetching,
|
||||
refetch: refetchContracts,
|
||||
} = useDjClearanceQueue();
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading: bookingsLoading,
|
||||
@@ -341,34 +278,26 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
() => (bookingQueue ?? []).map(toShipmentRow),
|
||||
[bookingQueue],
|
||||
);
|
||||
const allContractRows = useMemo(
|
||||
() => (contractQueue?.items ?? []).map(toContractRow),
|
||||
[contractQueue?.items],
|
||||
);
|
||||
|
||||
// KPI metrics span both queues, regardless of active tab or filters.
|
||||
// KPI metrics span the whole queue, regardless of filters.
|
||||
const metrics = useMemo(
|
||||
() => ({
|
||||
shipments: allShipmentRows.length,
|
||||
contracts: allContractRows.length,
|
||||
collectDo: allShipmentRows.filter((r) => r.action.key === "COLLECT_DO")
|
||||
.length,
|
||||
issueRo: allShipmentRows.filter((r) => r.action.key === "ISSUE_RO").length,
|
||||
roHolds: allShipmentRows.filter((r) => r.action.key === "RO_HOLD").length,
|
||||
}),
|
||||
[allShipmentRows, allContractRows],
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const statusOptions = useMemo(() => {
|
||||
const source =
|
||||
tab === "shipments"
|
||||
? allShipmentRows.map((r) => r.status)
|
||||
: allContractRows.map((r) => r.status);
|
||||
return [...new Set(source)].sort().map((s) => ({
|
||||
value: s,
|
||||
label: prettyStatus(s),
|
||||
}));
|
||||
}, [tab, allShipmentRows, allContractRows]);
|
||||
const statusOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allShipmentRows.map((r) => r.status))].sort().map((s) => ({
|
||||
value: s,
|
||||
label: prettyStatus(s),
|
||||
})),
|
||||
[allShipmentRows],
|
||||
);
|
||||
|
||||
const matchesShared = useCallback(
|
||||
(
|
||||
@@ -411,16 +340,10 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
[allShipmentRows, action, matchesShared],
|
||||
);
|
||||
|
||||
const contractRows = useMemo(
|
||||
() => allContractRows.filter((r) => matchesShared(r)),
|
||||
[allContractRows, matchesShared],
|
||||
);
|
||||
|
||||
const rows = tab === "shipments" ? shipmentRows : contractRows;
|
||||
const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading;
|
||||
const isError = tab === "contracts" ? contractsError : bookingsError;
|
||||
const isFetching = contractsFetching || bookingsFetching;
|
||||
const total = rows.length;
|
||||
const isLoading = bookingsLoading;
|
||||
const isError = bookingsError;
|
||||
const isFetching = bookingsFetching;
|
||||
const total = shipmentRows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const showEmpty = !isLoading && !isError && total === 0;
|
||||
|
||||
@@ -429,11 +352,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
return shipmentRows.slice(start, start + pagination.pageSize);
|
||||
}, [shipmentRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const pagedContractRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return contractRows.slice(start, start + pagination.pageSize);
|
||||
}, [contractRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(query || direction || freight || status || action);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
@@ -446,9 +364,8 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
}, [resetPage]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchContracts();
|
||||
void refetchBookings();
|
||||
}, [refetchContracts, refetchBookings]);
|
||||
}, [refetchBookings]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/gl-djibouti/clearance/${id}`),
|
||||
@@ -599,161 +516,12 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const contractColumns: ColumnDef<ContractRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Ship className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<RouteCell
|
||||
origin={r.originLabel}
|
||||
destination={r.destinationLabel}
|
||||
direction={r.tradeDirection}
|
||||
freightType={r.freightType}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "service",
|
||||
header: () => <span className={bookingTable.headerCell}>Service</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate maw={160}>
|
||||
{r.serviceTypeName}
|
||||
</Text>
|
||||
{r.customs ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
No customs
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clearance",
|
||||
header: () => <span className={bookingTable.headerCell}>Clearance</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={statusColor(r.clearanceStatus)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(r.clearanceStatus)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{phaseLabel(r.phase)}
|
||||
{r.cycleNumber > 1 ? ` · Cycle ${r.cycleNumber}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={statusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={2} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CalendarClock
|
||||
size={13}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.validUntil
|
||||
? `Until ${formatDate(r.validUntil)}`
|
||||
: r.validityDays
|
||||
? `${r.validityDays} days`
|
||||
: "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.estimatedShipmentDate ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Est. shipment {formatDate(r.estimatedShipmentDate)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
size: 40,
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
|
||||
subtitle="Customs shipments handed off to Djibouti GL — every clearance step lives on the shipment."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -769,7 +537,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={contractsLoading || bookingsLoading}
|
||||
loading={bookingsLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Shipments in queue",
|
||||
@@ -777,12 +545,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
icon: PackageCheck,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Contracts in queue",
|
||||
value: metrics.contracts,
|
||||
icon: FileSignature,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Imports — collect DO",
|
||||
value: metrics.collectDo,
|
||||
@@ -806,46 +568,6 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={tab}
|
||||
onChange={(v) => {
|
||||
setTab(v as QueueTab);
|
||||
setStatus(null);
|
||||
setAction(null);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
value: "shipments",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<PackageCheck size={15} />
|
||||
<Box visibleFrom="sm">Shipments</Box>
|
||||
<Badge size="sm" radius="sm" variant="light" color="edr-green">
|
||||
{allShipmentRows.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "contracts",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileSignature size={15} />
|
||||
<Box visibleFrom="sm">Contracts</Box>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{allContractRows.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
@@ -917,20 +639,18 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
radius="lg"
|
||||
w={190}
|
||||
/>
|
||||
{tab === "shipments" ? (
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={180}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
placeholder="DJ action"
|
||||
data={DJ_ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
w={180}
|
||||
/>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -957,9 +677,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
<Text c="dimmed">
|
||||
{hasFilters
|
||||
? "No records match these filters."
|
||||
: tab === "contracts"
|
||||
? "No Djibouti customs contracts yet."
|
||||
: "No shipment bookings awaiting a Djibouti action."}
|
||||
: "No shipments awaiting a Djibouti action."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
@@ -975,49 +693,26 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
</Stack>
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
{tab === "shipments" ? (
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
) : (
|
||||
<DataTable<ContractRow, unknown>
|
||||
columns={contractColumns}
|
||||
data={pagedContractRows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
<DataTable<ShipmentRow, unknown>
|
||||
columns={shipmentColumns}
|
||||
data={pagedShipmentRows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
@@ -30,8 +30,13 @@ import {
|
||||
type FleetFormFieldDef,
|
||||
type FleetResourceSlug,
|
||||
} from "@/pages/fleet/config/resources";
|
||||
import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import {
|
||||
isFleetServerPaginated,
|
||||
type FleetListFilters,
|
||||
type FleetRecord,
|
||||
} from "@/services/fleet/fleet.service";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
|
||||
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
|
||||
|
||||
@@ -53,6 +58,10 @@ const FleetResourcePage = () => {
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
// Wagons and locomotives page in the database; the rest still list in full
|
||||
// and page in the browser (see `pagedHandlers` in fleet.service).
|
||||
const serverPaged = isFleetServerPaginated(slug);
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
// Registration date range. Server-side list filters (status/yard/train) are
|
||||
// applied by the API; this narrows what comes back, alongside search.
|
||||
@@ -93,14 +102,42 @@ const FleetResourcePage = () => {
|
||||
if (wagonTypeId && wagonTypeId !== "ALL") {
|
||||
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
|
||||
}
|
||||
if (slug !== "locomotives" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
// The plain locomotives list has no server-side search — its page window
|
||||
// does, so the term is only sent on the paginated path.
|
||||
if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) {
|
||||
filters.search = debouncedSearch.trim();
|
||||
}
|
||||
return filters;
|
||||
}, [slug, listFilterValues, search]);
|
||||
}, [slug, listFilterValues, debouncedSearch, serverPaged]);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useQuery(
|
||||
api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
|
||||
// On the server-paged path the page window, the search and the registration
|
||||
// date range are all resolved by the API — nothing is filtered client-side.
|
||||
const pagedFilters = useMemo(
|
||||
(): FleetListFilters => ({
|
||||
...serverListFilters,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(dateFrom ? { createdFrom: dateFrom } : {}),
|
||||
...(dateTo ? { createdTo: dateTo } : {}),
|
||||
}),
|
||||
[serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
const listQuery = useQuery({
|
||||
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
|
||||
enabled: !serverPaged,
|
||||
});
|
||||
const pagedQuery = useQuery({
|
||||
...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }),
|
||||
enabled: serverPaged,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const activeQuery = serverPaged ? pagedQuery : listQuery;
|
||||
const { isLoading, isError, error } = activeQuery;
|
||||
const allRows = useMemo(
|
||||
() => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])),
|
||||
[serverPaged, pagedQuery.data, listQuery.data],
|
||||
);
|
||||
const create = useMutation(api.fleet.create.mutationOptions());
|
||||
const update = useMutation(api.fleet.update.mutationOptions());
|
||||
@@ -118,9 +155,15 @@ const FleetResourcePage = () => {
|
||||
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
|
||||
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
|
||||
api.wagons.list.queryOptions({ input: {} }),
|
||||
// Whole-fleet list for the "Wagon" form select — page-walked, so only fetch it
|
||||
// where a form actually offers that select (containers), not on every slug.
|
||||
const needsWagonOptions = Boolean(
|
||||
config?.formFields.some((field) => field.dynamicOptions === "wagons"),
|
||||
);
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({ input: {} }),
|
||||
enabled: needsWagonOptions,
|
||||
});
|
||||
const { data: containers = [], isLoading: containersLoading } = useQuery(
|
||||
api.containers.list.queryOptions(),
|
||||
);
|
||||
@@ -270,6 +313,9 @@ const FleetResourcePage = () => {
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
// The API already applied every filter and cut the page — re-filtering here
|
||||
// would drop rows the server deliberately returned.
|
||||
if (serverPaged) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
@@ -287,13 +333,19 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const totalCount = serverPaged
|
||||
? (pagedQuery.data?.meta.total ?? 0)
|
||||
: filteredRows.length;
|
||||
const pageCount = serverPaged
|
||||
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
|
||||
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
if (serverPaged) return filteredRows;
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filteredRows.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
|
||||
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
|
||||
if (!config) return [];
|
||||
@@ -584,7 +636,7 @@ const FleetResourcePage = () => {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filteredRows.length,
|
||||
totalCount,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -610,7 +662,7 @@ const FleetResourcePage = () => {
|
||||
emptyMessage={`No ${itemLabel} found`}
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={filteredRows.length}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
onEdit={
|
||||
canUpdate
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Ban,
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
@@ -155,6 +156,7 @@ function RouteTimeline({ route }: { route: RouteRecord }) {
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [viewing, setViewing] = useState<RouteRecord | null>(null);
|
||||
const [editing, setEditing] = useState<RouteRecord | null>(null);
|
||||
@@ -167,7 +169,26 @@ export default function RoutesPage() {
|
||||
const canUpdate = canFleetAction(user, "routes", "update");
|
||||
const canDelete = canFleetAction(user, "routes", "delete");
|
||||
|
||||
const routesQuery = useQuery(api.routes.list.queryOptions());
|
||||
const routesQuery = useQuery({
|
||||
...api.routes.listPaged.queryOptions({
|
||||
input: {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
},
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
// KPI counts stay whole-fleet (they must not move with the search box), so
|
||||
// they come from two count-only pages rather than the visible one.
|
||||
const totalCountQuery = useQuery(
|
||||
api.routes.listPaged.queryOptions({ input: { page: 1, pageSize: 1 } }),
|
||||
);
|
||||
const availableCountQuery = useQuery(
|
||||
api.routes.listPaged.queryOptions({
|
||||
input: { page: 1, pageSize: 1, status: "AVAILABLE" },
|
||||
}),
|
||||
);
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions());
|
||||
// Segment km are configured in Configuration → Yard Distances and resolved
|
||||
// by the API on save; this fetch is only to preview them in the form.
|
||||
@@ -182,35 +203,19 @@ export default function RoutesPage() {
|
||||
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
||||
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return routesQuery.data ?? [];
|
||||
return (routesQuery.data ?? []).filter((route) => {
|
||||
const searchable = [
|
||||
formatRouteLabel(route),
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...(route.milestones ?? []).map(
|
||||
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchable.includes(query);
|
||||
});
|
||||
}, [routesQuery.data, search]);
|
||||
// Narrowing the result set can strand the user on a page that no longer
|
||||
// exists (search down to 3 rows while on page 5 → empty table).
|
||||
useEffect(() => {
|
||||
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
|
||||
}, [debouncedSearch, setPagination]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
|
||||
const pagedRoutes = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filteredRoutes.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
|
||||
// Filtering, sorting and the page window all happen server-side.
|
||||
const pagedRoutes = routesQuery.data?.items ?? [];
|
||||
const matchCount = routesQuery.data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, routesQuery.data?.meta.totalPages ?? 1);
|
||||
|
||||
const allRoutes = routesQuery.data ?? [];
|
||||
const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length;
|
||||
const totalRoutes = totalCountQuery.data?.meta.total ?? 0;
|
||||
const availableCount = availableCountQuery.data?.meta.total ?? 0;
|
||||
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
@@ -489,11 +494,11 @@ export default function RoutesPage() {
|
||||
<KpiStrip
|
||||
loading={routesQuery.isLoading}
|
||||
items={[
|
||||
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
|
||||
{ label: "Total routes", value: totalRoutes, icon: RouteIcon },
|
||||
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
|
||||
{
|
||||
label: "Unavailable",
|
||||
value: allRoutes.length - availableCount,
|
||||
value: totalRoutes - availableCount,
|
||||
icon: Ban,
|
||||
color: "gray",
|
||||
},
|
||||
@@ -522,7 +527,7 @@ export default function RoutesPage() {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filteredRoutes.length,
|
||||
totalCount: matchCount,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -581,7 +586,7 @@ export default function RoutesPage() {
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={filteredRoutes.length}
|
||||
totalCount={matchCount}
|
||||
itemLabel="routes"
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Train as TrainIcon,
|
||||
TrainFront,
|
||||
Weight,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -48,6 +49,7 @@ import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -78,6 +80,8 @@ export default function TrainBuilderDetailPage() {
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
const [maintenanceTarget, setMaintenanceTarget] =
|
||||
useState<TrainCompositionWagon | null>(null);
|
||||
const { user } = useAuth();
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
const canDelete = canFleetAction(user, "trains", "delete");
|
||||
@@ -396,12 +400,7 @@ export default function TrainBuilderDetailPage() {
|
||||
"Could not detach wagon",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagonId) =>
|
||||
void withToast(
|
||||
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
|
||||
"Could not send wagon to maintenance",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -460,6 +459,54 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(maintenanceTarget)}
|
||||
onClose={() => setMaintenanceTarget(null)}
|
||||
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{maintenanceTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
is detached from train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{composition.code}
|
||||
</Text>{" "}
|
||||
and set to MAINTENANCE — it stays out of the available pool until it
|
||||
clears. The detach is stamped with the time and this train number in
|
||||
the wagon's history.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
|
||||
Keep in consist
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
leftSection={<Wrench size={16} />}
|
||||
loading={maintenanceWagon.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await maintenanceWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: maintenanceTarget!.id,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
||||
});
|
||||
setMaintenanceTarget(null);
|
||||
}, "Could not send wagon to maintenance")
|
||||
}
|
||||
>
|
||||
Send to maintenance
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={deactivateOpen}
|
||||
onClose={() => setDeactivateOpen(false)}
|
||||
|
||||
@@ -48,6 +48,8 @@ export function TransferFulfillModal({
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
status: Freight.WagonStatus.Available,
|
||||
// A coupled wagon cannot be moved out of its train by a transfer.
|
||||
unassigned: true,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
Inbox,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
import {
|
||||
STATUS_META,
|
||||
TransferProgress,
|
||||
TransferStatusBadge,
|
||||
wagonTypeLabel,
|
||||
yardLabel,
|
||||
} from "./wagon-transfer-ui";
|
||||
|
||||
const fmtTime = (iso?: string | null) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "—";
|
||||
|
||||
/** "Today" / "Yesterday" / "Mon 12 Jul 2026" — the header of one timeline block. */
|
||||
const dayLabel = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
const days = Math.round(
|
||||
(new Date().setHours(0, 0, 0, 0) - new Date(iso).setHours(0, 0, 0, 0)) /
|
||||
86_400_000,
|
||||
);
|
||||
if (days === 0) return "Today";
|
||||
if (days === 1) return "Yesterday";
|
||||
return d.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
/** Bucket an already-DESC-sorted list into day blocks, order preserved. */
|
||||
function groupByDay<T>(items: T[], at: (item: T) => string) {
|
||||
const groups: Array<{ key: string; label: string; items: T[] }> = [];
|
||||
for (const item of items) {
|
||||
const iso = at(item);
|
||||
const key = new Date(iso).toDateString();
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.key === key) last.items.push(item);
|
||||
else groups.push({ key, label: dayLabel(iso), items: [item] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const MOVEMENT_KIND_LABEL: Record<string, string> = {
|
||||
LOADED: "Carried cargo",
|
||||
EMPTY_REPOSITION: "Repositioned empty",
|
||||
MANUAL: "Manual move",
|
||||
};
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
return (
|
||||
<Stack align="center" gap={6} py="xl">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
|
||||
<History size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestItem({ request }: { request: WagonTransferRequest }) {
|
||||
const meta = STATUS_META[request.status];
|
||||
return (
|
||||
<Timeline.Item
|
||||
bullet={<Inbox size={12} />}
|
||||
color={meta?.color ?? "gray"}
|
||||
lineVariant="dotted"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 220 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(request.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={13} className="shrink-0 opacity-60" />
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(request.toYard)}
|
||||
</Text>
|
||||
<Badge variant="default" radius="sm" size="sm">
|
||||
{wagonTypeLabel(request.wagonType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
{request.reason ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{request.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TransferProgress request={request} />
|
||||
<TransferStatusBadge status={request.status} />
|
||||
<Text size="xs" c="dimmed" w={38} ta="right">
|
||||
{fmtTime(request.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Timeline.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function MovementItem({ movement }: { movement: WagonMovementRecord }) {
|
||||
return (
|
||||
<Timeline.Item
|
||||
bullet={<Truck size={12} />}
|
||||
color={movement.transferRequestId ? "edr-green" : "gray"}
|
||||
lineVariant="dotted"
|
||||
>
|
||||
<Group justify="space-between" align="center" gap="md" wrap="wrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 220 }}>
|
||||
<Badge variant="light" color="gray" radius="sm" ff="monospace">
|
||||
{movement.wagon?.wagonNumber ?? "Wagon"}
|
||||
</Badge>
|
||||
<Text size="sm" fw={500}>
|
||||
{yardLabel(movement.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={13} className="shrink-0 opacity-60" />
|
||||
<Text size="sm" fw={500}>
|
||||
{yardLabel(movement.toYard)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{movement.transferRequestId ? (
|
||||
<Tooltip label="Delivered against a transfer request" withArrow>
|
||||
<Badge variant="dot" color="teal" radius="sm" size="sm">
|
||||
Transfer
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" radius="sm" size="sm">
|
||||
{MOVEMENT_KIND_LABEL[movement.kind] ?? movement.kind}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" w={38} ta="right">
|
||||
{fmtTime(movement.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Timeline.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who moved what. A staffer sees their own activity; holders of
|
||||
* `transfer_history_all` can widen it to every staffer (the backend enforces
|
||||
* the scope regardless of the toggle).
|
||||
*/
|
||||
export default function TransferHistoryPanel() {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const [view, setView] = useState<"requests" | "movements">("requests");
|
||||
const [page, setPage] = useState(1);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions({
|
||||
input: { page, pageSize: 20 },
|
||||
}),
|
||||
enabled: !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({
|
||||
input: { page, pageSize: 20 },
|
||||
}),
|
||||
enabled: scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements = source.data?.movements ?? [];
|
||||
const meta = source.data?.meta;
|
||||
|
||||
const showingRequests = view === "requests";
|
||||
const total = showingRequests
|
||||
? (meta?.requestsTotal ?? 0)
|
||||
: (meta?.movementsTotal ?? 0);
|
||||
// Each list pages independently on the server; the pager follows the one on screen.
|
||||
const pageSize = meta?.pageSize ?? 20;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const groups = showingRequests
|
||||
? groupByDay(requests, (r) => r.createdAt)
|
||||
: groupByDay(movements, (m) => m.occurredAt);
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Transfer history</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{scopeAll
|
||||
? "Every staffer's requests and wagon moves"
|
||||
: "Requests you filed or fulfilled, and the wagons you moved"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
setView(v as "requests" | "movements");
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: "requests",
|
||||
label: `Requests ${meta?.requestsTotal ?? 0}`,
|
||||
},
|
||||
{
|
||||
value: "movements",
|
||||
label: `Wagons moved ${meta?.movementsTotal ?? 0}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{canSeeAll ? (
|
||||
<Switch
|
||||
label="All staff"
|
||||
checked={allStaff}
|
||||
onChange={(e) => {
|
||||
setAllStaff(e.currentTarget.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{source.isLoading ? (
|
||||
<Stack gap="sm">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height={44} radius="md" />
|
||||
))}
|
||||
</Stack>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
label={
|
||||
showingRequests
|
||||
? "No transfer requests recorded yet."
|
||||
: "No wagon moves recorded yet."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{groups.map((group) => (
|
||||
<Stack key={group.key} gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
{group.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
· {group.items.length}
|
||||
</Text>
|
||||
</Group>
|
||||
<Timeline
|
||||
bulletSize={22}
|
||||
lineWidth={2}
|
||||
active={group.items.length}
|
||||
>
|
||||
{showingRequests
|
||||
? (group.items as WagonTransferRequest[]).map((r) => (
|
||||
<RequestItem key={r.id} request={r} />
|
||||
))
|
||||
: (group.items as WagonMovementRecord[]).map((m) => (
|
||||
<MovementItem key={m.id} movement={m} />
|
||||
))}
|
||||
</Timeline>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} {showingRequests ? "request(s)" : "move(s)"} · page{" "}
|
||||
{meta?.page ?? page} of {totalPages}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
rightSection={<ChevronRight size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user