diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 64df33f35..a3c09df97 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, OnApplicationBootstrap, + RequestMethod, } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; @@ -31,6 +32,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; // import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; +import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -101,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { LoggerMiddleware } from "./logger.middleware"; +import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; @Module({ imports: [ @@ -173,6 +176,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ConsignmentsModule, LocomotivesModule, TruckTypesModule, + TransitAgentsModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, @@ -240,6 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware"; // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, + LoginAudienceMiddleware, ], }) export class AppModule implements OnApplicationBootstrap { @@ -328,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes("*"); + consumer.apply(LoginAudienceMiddleware).forRoutes( + { path: "auth/login", method: RequestMethod.POST }, + { path: "auth/mfa-verify", method: RequestMethod.POST }, + ); } } diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 5b027448c..cf4c37b2d 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -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(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 diff --git a/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts b/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts new file mode 100644 index 000000000..0e0484345 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts b/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts new file mode 100644 index 000000000..50fb9db2b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts b/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts new file mode 100644 index 000000000..f2c579f6b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts b/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts new file mode 100644 index 000000000..2958f5406 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary + * `freight.files` rows (resource `gl_exchange`), so they only need the metadata + * a free-form upload has and a catalog-driven one does not: the uploader's own + * title, who uploaded it (the only user allowed to change it afterwards) and + * whether the customer may see it in the portal. + */ +export class AddGlExchangeDocumentFields3030000000000 + implements MigrationInterface +{ + name = 'AddGlExchangeDocumentFields3030000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS title varchar(300), + ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid, + ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`, + ); + // Every read of a thread is "all files of one resource" — index the pair. + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_files_resource_lookup + ON freight.files (resource, resource_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`, + ); + await queryRunner.query( + `ALTER TABLE freight.files + DROP COLUMN IF EXISTS title, + DROP COLUMN IF EXISTS visible_to_customer, + DROP COLUMN IF EXISTS uploaded_by_user_id, + DROP COLUMN IF EXISTS uploaded_by_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts b/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts new file mode 100644 index 000000000..f587af9a7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTransitAgents3040000000000 implements MigrationInterface { + name = "AddTransitAgents3040000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.transit_agents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + valid_from date NOT NULL, + valid_to date NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active + ON freight.transit_agents (is_active) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts new file mode 100644 index 000000000..2303c3ef2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts new file mode 100644 index 000000000..3af58d02c --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts @@ -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 = { + 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(); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 6bef20630..6245195eb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Booking cancelled', msg); } + /** + * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and + * deep-linked to the Djibouti clearance page where the name is entered — the + * customs declaration is blocked until they answer. + */ + transitAssigneeRequested(b: Booking, note: string | null): void { + const msg = + `GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` + + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; + this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); + this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/gl-djibouti/clearance/${b.id}`, + }); + } + + /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void { + const msg = previous + ? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` + + `"${previous}" to "${assignee}".` + : `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` + + `The customs declaration can now be filed.`; + this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); + this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax — the customer must pay and upload the slip. */ @@ -287,11 +317,11 @@ export class BookingLifecycleNotifierService { }); } - /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + /** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */ finalInvoiceCreated(b: Booking, amount: number, currency: string): void { const msg = - `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + - `Please pay and upload the payment slip from the portal.`; + `A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` + + `Please review and approve it in the portal, then pay and upload the payment slip.`; void this.notifyContact(b, msg, 'FINAL INVOICE'); this.inApp(b, 'Final invoice issued', msg, { type: NotificationType.INVOICE_ISSUED, @@ -331,6 +361,15 @@ export class BookingLifecycleNotifierService { ); } + /** Customer approved the GL Djibouti final invoice — payment slip can follow. */ + finalInvoiceApprovedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Final invoice approved', + `The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`, + ); + } + /** Customer signed the booking contract. */ customerSignedToStaff(b: Booking): void { this.inAppStaff( @@ -362,6 +401,32 @@ export class BookingLifecycleNotifierService { ); } + /** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */ + draftDeclarationReady(b: Booking, price: number, currency: string): void { + const msg = + `A draft customs declaration for booking ${b.reference} is ready for your review — ` + + `estimated price ${price} ${currency}. Please accept it or request a change from the portal.`; + void this.notifyContact(b, msg, 'DRAFT DECLARATION READY'); + this.inApp(b, 'Draft declaration ready for review', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** + * The customer asked for a change on the draft declaration. This goes to + * STAFF, not the customer: GL Ethiopia is the one who has to send a + * corrected draft, and the clearance page is where they do it. + */ + draftDeclarationChangeRequested(b: Booking, note: string): void { + const msg = + `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + + `"${note}". Send a corrected draft from the clearance page.`; + this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { const label = diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 068ed53af..871d03c72 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 890ae6344..0cc7e59e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index cbbb999ef..5f82a8e3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index ec6e466b2..d7b24d514 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { 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.', ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 4ebfc9fab..54ce1660e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -823,6 +823,34 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/transit-assignee/request') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', + }) + async requestBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string | undefined, + ) { + const booking = await this.bookingClearanceService.requestTransitAssignee(id, note); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-assignee/assign') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: + 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', + }) + async assignBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + ) { + const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @@ -872,6 +900,59 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/draft-declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', + }) + async uploadBookingDraftDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @Body('price') priceRaw: string, + @Body('currency') currency: string | undefined, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDraftDeclaration( + id, + files ?? [], + Number(priceRaw), + currency ?? 'ETB', + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/draft-declaration/accept') + @ApiOperation({ + summary: + 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', + }) + async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.acceptDraftDeclaration(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/draft-declaration/change') + @ApiOperation({ + summary: + 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', + }) + async requestBookingDraftDeclarationChange( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.requestDraftDeclarationChange( + id, + note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a0b3b4e0e..b9ced4f96 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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 { + 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, + ): Promise { + await this.assertContractNotSuspended(id); + return super.update(id, data); + } + /** * Highest NNNNNN sequence already issued for `BK--…` references. * Includes soft-deleted bookings so the next number clears references that @@ -551,6 +611,17 @@ export class BookingsRepository extends BaseRepository { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + bookingId: string, + type: ReviewNoteType, + ): Promise { + 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 { '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 { .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 { { 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 { { 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') diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 0e858e702..81e5c833a 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -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(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 5a63beca6..242cee9d3 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts index 91171a793..969098196 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Booking } from './booking.entity'; -export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const; +export const REVIEW_NOTE_TYPES = [ + 'CHANGES_REQUESTED', + 'REJECTION', + 'STAFF_NOTE', + /** + * The customer asked GL Ethiopia to correct the draft customs declaration + * (price/files). One row per round — the draft/change-request loop can repeat. + */ + 'DRAFT_DECL_CHANGE_REQUEST', +] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; @Entity({ schema: 'freight', name: 'booking_review_note' }) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4ab9e397e..3d0603f5f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7b5cb77d6..df55493de 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -15,6 +15,9 @@ const generalImportBooking = { dutyRequired: true, roHoldReason: null, vesselDepartureDate: null, + // Djibouti already named the transit officer — the declaration gate is open. + transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'), + transitAssigneeName: 'Ahmed Bourhan', } as Booking; const generalExportBooking = { @@ -27,6 +30,8 @@ const generalExportBooking = { function makeService(overrides?: { booking?: Booking; workflowThrows?: boolean; + /** Resolve the input doc set with no required fields → every doc counts approved. */ + docsApproved?: boolean; }) { const booking = overrides?.booking ?? generalImportBooking; const bookingsRepository = { @@ -38,10 +43,14 @@ function makeService(overrides?: { }; const filesService = { upsertByCode: jest.fn().mockResolvedValue({}), + upload: jest.fn().mockResolvedValue({}), + deleteByCode: jest.fn().mockResolvedValue(undefined), findByResource: jest.fn().mockResolvedValue([]), }; const fileUploadSettingsService = { - getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + getByCode: overrides?.docsApproved + ? jest.fn().mockResolvedValue({ fields: [] }) + : jest.fn().mockRejectedValue(new Error('no setting')), }; const workflowService = { assertPriorCompleteForBooking: overrides?.workflowThrows @@ -49,6 +58,7 @@ function makeService(overrides?: { : jest.fn().mockResolvedValue(undefined), completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined), onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), listMilestonesForBooking: jest.fn().mockResolvedValue([]), resolvePhaseForBooking: jest.fn().mockReturnValue(null), @@ -91,7 +101,15 @@ function makeService(overrides?: { documentQueried: jest.fn(), dutySlipUploadedToStaff: jest.fn(), clearanceDocsUploadedToStaff: jest.fn(), + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), } as never, // notifier + { listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange + { + getAssignable: jest + .fn() + .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), + } as never, // transit agents ); return { @@ -186,6 +204,82 @@ describe('BookingClearanceService', () => { service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects an import declaration before Djibouti names the transit officer', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toThrow(/Request a transit assignee/i); + expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled(); + }); + + it('lets the export declaration through without a transit assignee', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalExportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await service.uploadDeclaration('b-export', [ + { fieldname: 'decl' } as Express.Multer.File, + ]); + + expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled(); + }); + }); + + describe('transit assignee handshake', () => { + it('refuses an assignment GL Ethiopia never asked for', async () => { + const { service } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.assignTransitAssignee('b-general', 'Ahmed Bourhan'), + ).rejects.toThrow(/has not requested a transit assignee/i); + }); + + it('stamps the ask and then the name', async () => { + const { service, bookingsRepository } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeName: null, + } as Booking, + }); + + await service.requestTransitAssignee('b-general', ' night shift '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeRequestedAt: expect.any(Date), + transitAssigneeRequestNote: 'night shift', + }), + ); + + await service.assignTransitAssignee('b-general', ' Ahmed Bourhan '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeName: 'Ahmed Bourhan', + transitAssigneeAssignedAt: expect.any(Date), + }), + ); + }); }); describe('uploadReleaseOrder', () => { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 69773010c..c9c15f8b0 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,10 +1,13 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, + isDraftDeclarationFileCode, type ClearanceFinalInvoiceSummary, + type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, + type GlExchangeDocument, } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; @@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { GlExchangeService } from './gl-exchange.service'; +import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -71,12 +76,44 @@ export interface BookingClearanceView { roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; + /** + * Pre-declaration handshake with GL Djibouti: who handles this shipment in + * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot + * file the import customs declaration before it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * Import only: the draft customs declaration GL Ethiopia sends before filing + * the real one. Present once a draft has been uploaded, regardless of + * accept state — `accepted` tells the caller which. + */ + draftDeclaration?: { + price: number; + currency: string; + files: Array<{ id: string; name: string; url: string }>; + accepted: boolean; + } | null; + /** + * The customer's open change request on the current draft declaration. + * Present only until GL sends a corrected draft; `rounds` counts how many + * times it has been sent back. + */ + draftDeclarationChangeRequest?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; @@ -87,6 +124,8 @@ export interface BookingClearanceView { t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; + /** Offload stats for this booking (what came off the train, and where). */ + offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ @@ -97,6 +136,8 @@ export interface BookingClearanceView { /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; + /** GL-shared documents this booking's uploader marked visible to the customer. */ + exchangeDocuments?: GlExchangeDocument[]; } @Injectable() @@ -111,15 +152,14 @@ export class BookingClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: BookingLifecycleNotifierService, + private readonly glExchangeService: GlExchangeService, + private readonly transitAgentsService: TransitAgentsService, ) {} - private async assertPhasedGeneralCustoms(booking: Booking): Promise { + private async assertPhasedCustoms(booking: Booking): Promise { if (!booking.customsClearingEnabled) { throw new BadRequestException('Phased clearance applies only to customs bookings.'); } - if (booking.contractKind !== 'GENERAL') { - throw new BadRequestException('Per-booking phased clearance applies to general contracts.'); - } if (!booking.contractId) { throw new BadRequestException('Booking is not linked to a contract.'); } @@ -127,7 +167,7 @@ export class BookingClearanceService { private async loadBooking(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - await this.assertPhasedGeneralCustoms(booking); + await this.assertPhasedCustoms(booking); return booking; } @@ -214,6 +254,11 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const draftDeclaration = this.buildDraftDeclaration(files, milestones); + const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest( + bookingId, + milestones, + ); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', @@ -241,6 +286,12 @@ export class BookingClearanceService { const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + // GL↔GL exchange documents shared with the customer. The two desks may work + // the thread on the booking (per-booking customs) or on its contract + // (pre-booking clearance), so the customer's view spans both. + const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer( + [bookingId, booking.contractId ?? ''], + ); return { bookingId, @@ -272,8 +323,21 @@ export class BookingClearanceService { : null, operationReady: boundary, preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + transitAssignee: { + requestedAt: booking.transitAssigneeRequestedAt + ? booking.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: booking.transitAssigneeRequestNote ?? null, + name: booking.transitAssigneeName ?? null, + assignedAt: booking.transitAssigneeAssignedAt + ? booking.transitAssigneeAssignedAt.toISOString() + : null, + }, dutyAdvice, + draftDeclaration, + draftDeclarationChangeRequest, workflowFiles, + exchangeDocuments, t1, train, gatepassGranted: gatepass.granted, @@ -284,6 +348,7 @@ export class BookingClearanceService { ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + offload: await this.glOperationsService.offloadState(bookingId, milestones), finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' @@ -330,6 +395,46 @@ export class BookingClearanceService { }; } + private buildDraftDeclaration( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['draftDeclaration'] { + const uploaded = milestones.find( + (m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED', + ); + if (!uploaded?.metadata) return null; + const price = uploaded.metadata.draftDeclarationPrice; + const currency = uploaded.metadata.draftDeclarationCurrency; + if (typeof price !== 'number' || typeof currency !== 'string') return null; + const draftFiles = files + .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')) + .map((f) => ({ id: f.id, name: f.name, url: f.url })); + const accepted = + milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status === + 'COMPLETED'; + return { price, currency, files: draftFiles, accepted }; + } + + private async buildDraftDeclarationChangeRequest( + bookingId: string, + milestones: ClearanceMilestone[], + ): Promise { + const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); + if (!uploaded || uploaded.status === 'COMPLETED') return null; + const notes = await this.bookingsRepository.findReviewNotes( + bookingId, + 'DRAFT_DECL_CHANGE_REQUEST', + ); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.note, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; @@ -352,14 +457,59 @@ export class BookingClearanceService { ); } - isPhasedGeneralCustomsBooking(booking: Booking): boolean { + /** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */ + isPhasedCustomsBooking(booking: Booking): boolean { return ( - Boolean(booking.customsClearingEnabled) && - booking.contractKind === 'GENERAL' && - Boolean(booking.contractId) + Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId) ); } + /** + * GL Ethiopia asks Djibouti to name the officer who will handle this shipment + * in transit. The import declaration is gated on the answer, so this is the + * first thing ET does once the customer documents are approved. Re-requesting + * is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + bookingId: string, + note: string | undefined, + ): Promise { + const booking = await this.loadBooking(bookingId); + + await this.bookingsRepository.update(bookingId, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestNote: note?.trim() || null, + } as never); + + this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null); + return this.bookingsService.findById(bookingId); + } + + /** + * GL Djibouti picks the transit officer from the admin-managed roster — + * rejected unless the agent is active and inside its validity window. + * Answering unblocks the declaration for Ethiopia. A later call overwrites + * the name (reassignment) and re-notifies. + */ + async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (!booking.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this shipment yet.', + ); + } + const agent = await this.transitAgentsService.getAssignable(transitAgentId); + + const previous = booking.transitAssigneeName ?? null; + await this.bookingsRepository.update(bookingId, { + transitAssigneeName: agent.name, + transitAssigneeAssignedAt: new Date(), + } as never); + + this.notifier.transitAssigneeAssigned(booking, agent.name, previous); + return this.bookingsService.findById(bookingId); + } + async uploadDeclaration( bookingId: string, files: Express.Multer.File[], @@ -373,6 +523,16 @@ export class BookingClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // Import only: the declaration is filed against whoever physically handles + // the shipment in Djibouti, so that name must be in first. Exports have no + // such handshake — their Djibouti steps come after the declaration. + if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) { + throw new BadRequestException( + booking.transitAssigneeRequestedAt + ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' + : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', + ); + } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { @@ -398,12 +558,6 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); - // Export: the declaration is the last GL ET pre-operation action — release - // immediately so the customer can proceed without a separate confirm click. - if (tradeDirection === 'EXPORT') { - await this.workflowService.onExportReleasedForBooking(bookingId, userId); - } - return this.bookingsService.findById(bookingId); } @@ -460,6 +614,124 @@ export class BookingClearanceService { return this.bookingsService.findById(bookingId); } + /** + * GL Ethiopia sends a draft customs declaration (estimated price + files) for + * the customer to review before the real declaration is filed. Repeatable — + * each call replaces the previous draft's files/price and re-arms the step, + * which is what a re-send after a change request needs. + */ + async uploadDraftDeclaration( + bookingId: string, + files: Express.Multer.File[], + price: number, + currency: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + if (files.length === 0) { + throw new BadRequestException('No draft declaration documents uploaded'); + } + if (!Number.isFinite(price) || price < 0) { + throw new BadRequestException('A valid estimated price is required.'); + } + // Backfills the two new milestone rows for bookings seeded before this step + // existed — a blind complete() 404s on a booking with no such row yet. + await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT'); + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DRAFT_DECLARATION_UPLOADED', + ); + + await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'DRAFT_DECLARATION_UPLOADED', + { draftDeclarationPrice: price, draftDeclarationCurrency: currency }, + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.draftDeclarationReady(updated, price, currency); + return updated; + } + + /** + * The customer accepts the draft declaration — GL Ethiopia may now file the + * real customs declaration. + */ + async acceptDraftDeclaration(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); + if (uploaded?.status !== 'COMPLETED') { + throw new BadRequestException('There is no draft declaration to accept yet.'); + } + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED'); + return this.bookingsService.findById(bookingId); + } + + /** + * The customer sends the draft declaration back with a reason. Nothing is + * filed; the upload milestone reopens so the step becomes actionable again + * for GL Ethiopia, with the customer's message shown beside it. GL re-sends + * (same endpoint as the first time), which closes the request — the loop may + * run as many rounds as it takes. + */ + async requestDraftDeclarationChange( + bookingId: string, + note: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what needs to change so GL can correct the draft.', + ); + } + + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') { + throw new BadRequestException('There is no draft declaration to request a change on yet.'); + } + if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The draft declaration has already been accepted — contact GL Ethiopia directly.', + ); + } + + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + 'DRAFT_DECL_CHANGE_REQUEST', + userId, + ); + // Back to GL: reopening the milestone is what re-arms the step (the + // stepper picks its active step from milestone completion). + await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.draftDeclarationChangeRequested(updated, note.trim()); + return updated; + } + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -664,6 +936,10 @@ export class BookingClearanceService { 'RELEASE_ORDER_SECURED', userId, ); + // Release Order is now the last GL DJ pre-operation action (it follows the + // declaration) — release immediately so booking creation unlocks without a + // separate confirm click. + await this.workflowService.onExportReleasedForBooking(bookingId, userId); return { booking: await this.bookingsService.findById(bookingId), hold: false }; } @@ -720,7 +996,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } @@ -733,7 +1009,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if ( belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 99fed335a..1ca7cd84f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -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.', diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 648d9666f..b3241d10c 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -18,6 +18,8 @@ const IMPORT_DEFS: Record> = { IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false }, PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true }, DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false }, + DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true }, + DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false }, UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false }, DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index 9b17a3e76..325987f0c 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -353,10 +353,10 @@ export class ClearanceWorkflowService { if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; if (tradeDirection === 'EXPORT') { + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone('RELEASE_ORDER_SECURED')) { return ContractDocPhase.GlDjCollection; } - if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; return ContractDocPhase.GlEtPostClearance; } @@ -450,13 +450,6 @@ export class ClearanceWorkflowService { : 'Proceed to request operation'; if (tradeDirection === 'EXPORT') { - if (!isDone('RELEASE_ORDER_SECURED')) { - return { - actor: 'GL_DJ', - action: 'Upload Release Order and vessel departure date', - milestoneCode: 'RELEASE_ORDER_SECURED', - }; - } if (!isDone('DECLARED')) { return { actor: 'GL_ET', @@ -464,6 +457,13 @@ export class ClearanceWorkflowService { milestoneCode: 'DECLARED', }; } + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } if (!isDone(EXPORT_BOUNDARY)) { return { actor: 'GL_ET', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 563f056d2..c222ffc16 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -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) { + 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( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 4bc768cc1..84465145d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -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(), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts new file mode 100644 index 000000000..6b5f54c91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -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; + }; + + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 7e657cc07..c567f71ce 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 { 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 { + private async assertGate( + contract: Contract, + isGlActor: boolean, + isInitiate = false, + ): Promise { + // 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 { + 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 { 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 0ceea38ed..2ebd8fc70 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -7,6 +7,7 @@ import { import { ContractDocPhase, type ClearanceFinalInvoiceSummary, + type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, @@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; +import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { ClearanceMilestone, type RiskAssignmentRecord, @@ -34,7 +36,7 @@ import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -140,6 +142,8 @@ export interface ContractClearanceView { t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; + /** Offload stats for the linked booking (null until one exists). */ + offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ @@ -165,6 +169,7 @@ export class ContractClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: ContractNotifierService, + private readonly transitAgentsService: TransitAgentsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -433,6 +438,9 @@ export class ContractClearanceService { ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + offload: cycle?.bookingId + ? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones) + : null, finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' @@ -1060,46 +1068,6 @@ export class ContractClearanceService { }); } - /** - * Operations queue: self-clearance (Path A) contracts awaiting Operations - * review of the customer's own clearance documents. - */ - /** - * Statuses a non-customs contract passes through around Operations - * clearance review — the set a caller may narrow {@link opsQueue} to. - */ - private static readonly OPS_CLEARANCE_STATUSES = [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - 'FULLY_EXECUTED', - 'CONTRACT_ACTIVE', - 'ACTIVE_SHIPMENT_IN_PROGRESS', - 'CONTRACT_CLOSED', - 'CANCELLED', - ]; - - async opsQueue(filter: FilterContractDto): Promise { - // 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 { @@ -1157,20 +1125,18 @@ export class ContractClearanceService { } /** - * GL Djibouti names the transit officer — free text, because the person is - * not a platform user. Answering unblocks the declaration for Ethiopia. A - * later call overwrites the name (reassignment) and re-notifies. + * GL Djibouti picks the transit officer from the admin-managed roster — + * rejected unless the agent is active and inside its validity window. + * Answering unblocks the declaration for Ethiopia. A later call overwrites + * the name (reassignment) and re-notifies. */ async assignTransitAssignee( contractId: string, - assignee: string, + transitAgentId: string, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); - if (!assignee?.trim()) { - throw new BadRequestException('Name the officer who will handle the transit.'); - } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); if (!cycle.transitAssigneeRequestedAt) { @@ -1178,16 +1144,17 @@ export class ContractClearanceService { 'GL Ethiopia has not requested a transit assignee for this clearance yet.', ); } + const agent = await this.transitAgentsService.getAssignable(transitAgentId); const previous = cycle.transitAssigneeName ?? null; await this.contractsRepository.updateCycle(cycle.id, { - transitAssigneeName: assignee.trim(), + transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), transitAssigneeAssignedByUserId: userId ?? null, }); const updated = await this.contractsService.findById(contractId); - this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous); + this.notifier.transitAssigneeAssigned(updated, agent.name, previous); return updated; } @@ -1226,6 +1193,15 @@ export class ContractClearanceService { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); await this.ensureDeclarationPrerequisites(contractId, contract); + // The draft-declaration accept/change-request loop only exists on the + // booking-scoped clearance page (portal customers never see contract-scoped + // clearance) — skip it here so it can never block the ONE_TIME pre-booking + // flow, which has no UI to complete it. Contracts seeded before this step + // existed have no such row to skip — ignore, `assertPriorComplete` below + // already tolerates a missing milestone as "not required". + await this.workflowService + .skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED']) + .catch(() => undefined); await this.workflowService.assertPriorComplete( contractId, contract.tradeDirection, @@ -1255,12 +1231,6 @@ export class ContractClearanceService { }); } - // Export: the declaration is the last GL ET pre-booking action — release - // immediately so booking creation unlocks without a separate confirm click. - if (contract.tradeDirection === 'EXPORT') { - await this.workflowService.onExportReleased(contractId, userId); - } - return this.contractsService.findById(contractId); } @@ -1601,6 +1571,10 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlEtOutput, }); await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + // Release Order is now the last GL DJ pre-booking action (it follows the + // declaration) — release immediately so booking creation unlocks without a + // separate confirm click. + await this.workflowService.onExportReleased(contractId, userId); return { contract: await this.contractsService.findById(contractId), hold: false }; } @@ -1693,80 +1667,4 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } - /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ - async etQueue(filter: FilterContractDto): Promise { - 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 { - 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, - }, - }; - } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts new file mode 100644 index 000000000..1290b2e90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts @@ -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 = {}) => + ({ + 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; + } + ).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))).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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts index 0ba682294..b86b08444 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => { {} as never, // dropdownSettingsService {} as never, // glOperationsService notifier as never, + {} as never, // transitAgentsService ); build([ milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 11681a28f..92a313569 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts new file mode 100644 index 000000000..5cf24d747 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index b579acdfb..1a4de8245 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { + 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 { + 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 { + 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 { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 74574ed28..8c004ade0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { + CancelContractDto, RejectContractDto, RejectStepDto, RequestChangesDto, + ResumeContractDto, + SuspendContractDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; @@ -273,6 +276,18 @@ export class ContractsController { return this.clearanceService.queue(filter); } + // Must stay ABOVE @Get(':id') — declared after it, Nest matched the literal + // path as an id and ParseUUIDPipe answered 400 "uuid is expected". + @Get('awaiting-shipment') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.', + }) + awaitingShipmentContracts() { + return this.contractBookingService.awaitingShipmentContracts(); + } + @Get(':id') @ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' }) async findOne( @@ -453,6 +468,65 @@ export class ContractsController { ); } + @Post(':id/suspend') + @BookingStaff(FREIGHT_PERMS.contracts.suspend) + @ApiOperation({ + summary: 'Staff freeze a signed contract (reversible, any post-signature step)', + }) + suspend( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SuspendContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.suspend( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); + } + + @Post(':id/resume') + @BookingStaff(FREIGHT_PERMS.contracts.suspend) + @ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' }) + resume( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ResumeContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.resume( + id, + dto.note, + resolveAuthUserId(user), + user, + ); + } + + @Post(':id/cancel') + @ApiOperation({ + summary: 'Customer cancels their own contract (blocked while a booking is live)', + }) + async cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelContractDto, + @CurrentUser() user: TCurrentUser, + ) { + // Same ownership rule as renew: staff with bookings.view/contracts.view pass + // through, everyone else must own the contract's company. + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return this.transitionService.cancelByCustomer( + id, + dto.reason, + resolveAuthUserId(user), + ); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) @@ -763,16 +837,16 @@ export class ContractsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', }) assignTransitAssignee( @Param('id', ParseUUIDPipe) id: string, - @Body('assignee') assignee: string, + @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.assignTransitAssignee( id, - assignee, + transitAgentId, resolveAuthUserId(user), ); } @@ -924,31 +998,8 @@ export class ContractsController { return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); } - @Get('clearance/et-queue') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) - etClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.etQueue(filter); - } - - @Get('clearance/dj-queue') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) - djClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.djQueue(filter); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── - @Get('clearance/ops-queue') - @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) - @ApiOperation({ - summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review', - }) - opsClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.opsQueue(filter); - } - @Post(':id/clearance/ops-review') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ @@ -1017,7 +1068,7 @@ export class ContractsController { @Post(':id/bookings/initiate') @ApiOperation({ summary: - 'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).', + 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) initiateBooking( @Param('id', ParseUUIDPipe) id: string, @@ -1243,6 +1294,20 @@ export class ContractsController { ); } + @Post('bookings/:bookingId/final-invoice/approve') + @ApiOperation({ + summary: 'Customer approves the drafted final invoice — unlocks the payment slip', + }) + approveFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.approveFinalInvoice( + bookingId, + resolveAuthUserId(user), + ); + } + @Post('bookings/:bookingId/final-invoice-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @@ -1335,7 +1400,7 @@ export class ContractsController { ) { const file = (files ?? [])[0]; const booking = await this.bookingsService.findById(bookingId); - if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) { return this.bookingClearanceService.uploadDutySlip(bookingId, file); } return this.glOperationsService.uploadDutySlip(bookingId, file); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 679786c13..658acf39d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; +import { TransitAgentsModule } from '../transit-agents/transit-agents.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { GlExchangeController } from './gl-exchange.controller'; +import { GlExchangeService } from './gl-exchange.service'; import { BookingRequestService } from './booking-request.service'; import { BookingRequestRepository } from './booking-request.repository'; @@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // Provides the admin-editable contract document templates consumed by // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, + TransitAgentsModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), @@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum config.get('app.cbeExchange') ?? {}, }), ], - controllers: [ContractsController], + controllers: [ContractsController, GlExchangeController], providers: [ ContractsService, ContractsRepository, @@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractBookingService, ClearanceMilestoneService, GlOperationsService, + GlExchangeService, BookingRequestService, BookingRequestRepository, // Contract PDF providers (template resolution + render + PDF) — stateless @@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum BookingClearanceService, ContractBookingService, ClearanceMilestoneService, + GlExchangeService, ], }) export class ContractsModule {} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index b9c22d1af..49ef3a29f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -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 { } /** - * 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 { 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 { // ── 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 { + 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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 43aab750f..24aa2b597 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 + > | 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 { 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; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 9a86a5b4e..6aa36b13d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 14e3b86dd..57bcf4820 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -46,6 +46,9 @@ export interface MilestoneMetadata { declarationSerial?: string; /** When the gate pass was physically granted (GL DJ captures the time). */ gatepassAt?: string; + /** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */ + draftDeclarationPrice?: number; + draftDeclarationCurrency?: string; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 4d2a46365..5b64fe5f7 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -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]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 3fe48ea27..cdf6b4ecf 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts new file mode 100644 index 000000000..4875d9e24 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts @@ -0,0 +1,102 @@ +import { BadRequestException } from '@nestjs/common'; +import { Freight } from '@edr/types'; + +import { GlOperationsService } from './gl-operations.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The GL Djibouti final invoice lands as a DRAFT: the customer must approve it + * (which issues it) before a payment slip is accepted. + */ +describe('GlOperationsService — final invoice approval', () => { + const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({ + id: 'inv-1', + invoiceNumber: 'INV-1', + status, + totalAmount: 1500, + currency: 'ETB', + issuedAt, + paidAt: null, + }); + + let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock }; + let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock }; + let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock }; + let service: GlOperationsService; + + beforeEach(() => { + billingService = { + findInvoice: jest.fn(), + updateStatus: jest.fn().mockResolvedValue(undefined), + }; + filesService = { + findByResource: jest.fn().mockResolvedValue([]), + upsertByCode: jest.fn().mockResolvedValue(undefined), + }; + notifier = { + finalInvoiceApprovedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + }; + const dataSource = { + getRepository: (entity: unknown) => + entity === Booking + ? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) } + : { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) }, + }; + + service = new GlOperationsService( + dataSource as never, + filesService as never, + {} as never, // milestoneService + billingService as never, + notifier as never, + ); + }); + + it('issues the draft on customer approval and reports approvedAt', async () => { + const issued = new Date('2026-07-28T09:00:00.000Z'); + billingService.findInvoice + .mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft)) + .mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued)); + + const summary = await service.approveFinalInvoice('bk-1', 'user-1'); + + expect(billingService.updateStatus).toHaveBeenCalledWith( + 'inv-1', + Freight.InvoiceStatus.Issued, + ); + expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled(); + expect(summary.approvedAt).toBe(issued.toISOString()); + }); + + it('is a no-op when the invoice was already approved', async () => { + billingService.findInvoice.mockResolvedValue( + invoice(Freight.InvoiceStatus.Issued, new Date()), + ); + + await service.approveFinalInvoice('bk-1'); + + expect(billingService.updateStatus).not.toHaveBeenCalled(); + }); + + it('refuses a payment slip while the invoice is still a draft', async () => { + billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft)); + + await expect( + service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never), + ).rejects.toThrow(BadRequestException); + expect(filesService.upsertByCode).not.toHaveBeenCalled(); + }); + + it('accepts the payment slip once approved', async () => { + billingService.findInvoice.mockResolvedValue( + invoice(Freight.InvoiceStatus.Issued, new Date()), + ); + + await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ code: 'final_invoice_slip' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts new file mode 100644 index 000000000..6ea0dacbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts @@ -0,0 +1,133 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { actorLabel } from '../warehouses/current-actor.util'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; + +import { + GlExchangeService, + type GlExchangeActor, + type GlExchangeSide, +} from './gl-exchange.service'; + +/** Either GL desk may read and post; ownership decides who may edit. */ +const GL_EXCHANGE_PERMS = [ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, +]; + +/** Multipart bodies arrive as strings — "true"/"1" mean checked. */ +const asBool = (raw: string | boolean | undefined): boolean => + raw === true || raw === 'true' || raw === '1'; + +@ApiTags('gl-exchange') +@ApiBearerAuth() +@Controller('gl-exchange') +export class GlExchangeController { + constructor(private readonly exchangeService: GlExchangeService) {} + + @Get(':entityId') + @BookingStaff(GL_EXCHANGE_PERMS) + @ApiOperation({ + summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', + }) + list( + @Param('entityId', ParseUUIDPipe) entityId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.list(entityId, resolveAuthUserId(user)); + } + + @Post(':entityId') + @BookingStaff(GL_EXCHANGE_PERMS) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Share a document with the other GL desk' }) + upload( + @Param('entityId', ParseUUIDPipe) entityId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body('title') title: string, + @Body('visibleToCustomer') visibleToCustomer: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.upload( + entityId, + file, + { title, visibleToCustomer: asBool(visibleToCustomer) }, + this.actor(user), + ); + } + + @Patch('documents/:documentId') + @BookingStaff(GL_EXCHANGE_PERMS) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Uploader edits a shared document (title, visibility, file)', + }) + update( + @Param('documentId', ParseUUIDPipe) documentId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body('title') title: string | undefined, + @Body('visibleToCustomer') visibleToCustomer: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.update( + documentId, + { + title, + visibleToCustomer: + visibleToCustomer == null ? undefined : asBool(visibleToCustomer), + }, + file, + resolveAuthUserId(user), + ); + } + + @Delete('documents/:documentId') + @BookingStaff(GL_EXCHANGE_PERMS) + @HttpCode(204) + @ApiOperation({ summary: 'Uploader removes a shared document' }) + async remove( + @Param('documentId', ParseUUIDPipe) documentId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.exchangeService.remove(documentId, resolveAuthUserId(user)); + } + + /** + * Which desk is posting. A user holding only the Djibouti actions permission + * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) + * posts as Ethiopia. + */ + private actor(user: TCurrentUser): GlExchangeActor { + const side: GlExchangeSide = + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) + ? 'DJ' + : 'ET'; + return { + userId: resolveAuthUserId(user), + name: actorLabel(user) ?? null, + side, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts new file mode 100644 index 000000000..1b0d88b2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts @@ -0,0 +1,198 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { Freight } from '@edr/types'; + +import { FilesService } from '../files/files.service'; +import type { FileRecord } from '../files/entities/file.entity'; + +/** + * `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The + * thread is keyed by the entity the two desks are working on — a booking id on + * the per-booking clearance pages, a contract id on the pre-booking ones — so + * both desks opening the same record see the same documents. + */ +export const GL_EXCHANGE_RESOURCE = 'gl_exchange'; + +export type GlExchangeSide = 'ET' | 'DJ'; + +export interface GlExchangeActor { + userId: string; + name?: string | null; + side: GlExchangeSide; +} + +export interface GlExchangeUploadInput { + title: string; + visibleToCustomer: boolean; +} + +/** + * Free-form document exchange between the two Global Logistics desks. Anything + * either side needs the other to have (scans, correspondence, corrected forms) + * lands here under a title they choose, instead of a fixed clearance slot. + * + * Rules, all enforced here rather than in the UI: + * - both desks read every document in a thread, whoever uploaded it; + * - only the uploader may retitle, replace or remove one; + * - the customer sees only what its uploader marked visible. + */ +@Injectable() +export class GlExchangeService { + constructor(private readonly filesService: FilesService) {} + + /** Every document on one thread, newest first, from a GL desk's view. */ + async list( + entityId: string, + viewerId: string, + ): Promise { + const records = await this.filesService.findByResource( + entityId, + GL_EXCHANGE_RESOURCE, + ); + return this.sort(records.map((r) => this.toDto(r, viewerId))); + } + + /** + * The customer-facing slice across several threads (a booking and the + * contract it belongs to). Never exposes internal documents, and never marks + * anything editable — the customer is not a GL desk. + */ + async listVisibleToCustomer( + entityIds: string[], + ): Promise { + const ids = [...new Set(entityIds.filter(Boolean))]; + if (ids.length === 0) return []; + const grouped = await this.filesService.findByResourceIdsGrouped( + ids, + GL_EXCHANGE_RESOURCE, + ); + const visible = [...grouped.values()] + .flat() + .filter((r) => r.visibleToCustomer); + return this.sort(visible.map((r) => this.toDto(r, null))); + } + + async upload( + entityId: string, + file: Express.Multer.File | undefined, + input: GlExchangeUploadInput, + actor: GlExchangeActor, + ): Promise { + const title = input.title?.trim(); + if (!title) throw new BadRequestException('A document title is required.'); + if (!file) throw new BadRequestException('A file is required.'); + + const record = await this.filesService.upload({ + resourceId: entityId, + resource: GL_EXCHANGE_RESOURCE, + // No fixed slot exists for these — `code` carries the uploading desk, so + // a document's origin survives even if the uploader leaves the org. + code: actor.side, + file, + title, + visibleToCustomer: input.visibleToCustomer, + uploadedByUserId: actor.userId, + uploadedByName: actor.name ?? null, + }); + return this.toDto(record, actor.userId); + } + + /** + * Retitle, re-share or replace a document. Uploader only — the other desk + * reads it but never edits it. A replacement file supersedes the old record + * (soft-deleted, bytes kept) and carries its metadata forward. + */ + async update( + documentId: string, + patch: { title?: string; visibleToCustomer?: boolean }, + file: Express.Multer.File | undefined, + actorId: string, + ): Promise { + const record = await this.assertUploader(documentId, actorId); + const title = patch.title?.trim(); + if (patch.title != null && !title) { + throw new BadRequestException('A document title is required.'); + } + + if (file) { + const replacement = await this.filesService.upload({ + resourceId: record.resourceId, + resource: GL_EXCHANGE_RESOURCE, + code: record.code, + file, + title: title ?? record.title, + visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer, + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + }); + await this.filesService.remove(record.id); + return this.toDto(replacement, actorId); + } + + const updated = await this.filesService.updateMeta(record.id, { + ...(title ? { title } : {}), + ...(patch.visibleToCustomer != null + ? { visibleToCustomer: patch.visibleToCustomer } + : {}), + }); + return this.toDto(updated, actorId); + } + + /** Uploader-only removal (soft delete — the stored bytes are kept). */ + async remove(documentId: string, actorId: string): Promise { + const record = await this.assertUploader(documentId, actorId); + await this.filesService.remove(record.id); + } + + private async assertUploader( + documentId: string, + actorId: string, + ): Promise { + const record = await this.filesService.findById(documentId); + if (record.resource !== GL_EXCHANGE_RESOURCE) { + throw new NotFoundException(`Exchange document ${documentId} not found`); + } + if (record.uploadedByUserId !== actorId) { + throw new ForbiddenException( + 'Only the person who uploaded this document can change it.', + ); + } + return record; + } + + private sort( + docs: Freight.GlExchangeDocument[], + ): Freight.GlExchangeDocument[] { + return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)); + } + + private toDto( + record: FileRecord, + viewerId: string | null, + ): Freight.GlExchangeDocument { + return { + id: record.id, + entityId: record.resourceId, + // Pre-title rows (none in practice) fall back to the filename so a list + // never renders a blank row. + title: record.title ?? record.name, + side: record.code === 'DJ' ? 'DJ' : 'ET', + visibleToCustomer: record.visibleToCustomer, + uploadedById: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + uploadedAt: record.createdAt.toISOString(), + file: { + id: record.id, + name: record.name, + url: record.url, + size: record.size, + mimeType: record.mimeType, + }, + canEdit: viewerId != null && record.uploadedByUserId === viewerId, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 106acdb0b..c7be2372b 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -282,6 +282,85 @@ export class GlOperationsService { }; } + /** + * Offload facts for a booking, read-only: what came off the train at its + * destination (containers, wagons, tonnes) and where the goods went. Sourced + * from the booking's warehouse-inventory row — written by the auto-unload + * that runs on train arrival for both directions. + */ + async offloadState( + bookingId: string, + milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>, + ): Promise { + const [row]: Array<{ + destination: string | null; + containers: number; + wagons: number; + bookedWeight: string | null; + inventoryStatus: string | null; + unloadedAt: Date | null; + grnNumber: string | null; + offloadedWeight: string | null; + warehouse: string | null; + warehouseYard: string | null; + zone: string | null; + }> = await this.dataSource.query( + `SELECT COALESCE(dy.label, dy.code) AS "destination", + (SELECT COUNT(*)::int + FROM freight.booking_container bc + JOIN freight.booking_container_units bcu + ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL + WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers", + (SELECT COUNT(*)::int + FROM freight.wagon_booking_allocations wba + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons", + b.cargo_total_weight_vgm AS "bookedWeight", + inv.status AS "inventoryStatus", + inv.unloaded_at AS "unloadedAt", + inv.grn_number AS "grnNumber", + inv.weight AS "offloadedWeight", + wh.name AS "warehouse", + wy.name AS "warehouseYard", + wz.name AS "zone" + FROM freight.bookings b + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN LATERAL ( + SELECT i.* + FROM freight.warehouse_inventory i + WHERE i.booking_id = b.id AND i.deleted_at IS NULL + ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC + LIMIT 1 + ) inv ON TRUE + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id + LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + + const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED'); + const offloadedAt = + milestone?.status === 'COMPLETED' && milestone.triggeredAt + ? new Date(milestone.triggeredAt).toISOString() + : (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null); + // The warehouse records the real offloaded tonnage; before it does, the + // booked VGM is the best number we have. + const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0); + const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › '); + + return { + offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt), + offloadedAt, + destination: row?.destination ?? null, + containers: row?.containers ?? 0, + wagons: row?.wagons ?? 0, + weightTons: weight || null, + grnNumber: row?.grnNumber ?? null, + location: location || null, + inventoryStatus: row?.inventoryStatus ?? null, + }; + } + /** * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass * is secured on the train schedule (which itself follows wagon allocation). @@ -381,8 +460,9 @@ export class GlOperationsService { /** * GL Djibouti raises the post-offload final invoice (export): manual amount + - * attached invoice document. The customer pays offline and attaches a slip; - * GL (ET or DJ) then confirms to settle it. + * attached invoice document. It is issued as a DRAFT the customer must approve + * first; only then do they pay offline and attach a slip, and GL (ET or DJ) + * confirms to settle it. */ async createFinalInvoice( bookingId: string, @@ -445,7 +525,8 @@ export class GlOperationsService { amount: input.amount, }, ], - status: Freight.InvoiceStatus.Issued, + // DRAFT until the customer approves it — approveFinalInvoice issues it. + status: Freight.InvoiceStatus.Draft, }); await this.filesService.upsertByCode({ @@ -467,6 +548,40 @@ export class GlOperationsService { return summary; } + /** + * Customer approves the drafted final invoice — issues it, which is what + * unlocks the payment slip upload. Idempotent: approving twice is a no-op. + */ + async approveFinalInvoice( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been raised for this shipment.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + if (invoice.status === Freight.InvoiceStatus.Draft) { + await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued); + this.notifier.finalInvoiceApprovedToStaff(booking); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + /** Customer attaches the payment slip for the final invoice. */ async uploadFinalInvoiceSlip( bookingId: string, @@ -483,6 +598,11 @@ export class GlOperationsService { if (!invoice) { throw new BadRequestException('No final invoice has been issued for this shipment.'); } + if (invoice.status === Freight.InvoiceStatus.Draft) { + throw new BadRequestException( + 'Approve the final invoice before attaching a payment slip.', + ); + } if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException('The final invoice is already paid.'); } @@ -688,6 +808,8 @@ export class GlOperationsService { description: line?.description ?? null, invoiceFile: toRef('final_invoice'), slipFile: toRef('final_invoice_slip'), + // Issuing IS the customer approval (createFinalInvoice leaves it DRAFT). + approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null, confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, }; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index f155a27be..78d0d6b47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common'; import { catalogEntriesForTradeDirection, declarationFileLabel, + draftDeclarationFileLabel, isDeclarationFileCode, + isDraftDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, @@ -72,6 +74,52 @@ export async function persistDeclarationUploads( ); } +/** Require at least one draft declaration file in the upload batch. */ +export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No draft declaration documents uploaded'); + } +} + +/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDraftDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `draft_declaration_${index}`, + })); +} + +/** Replace all draft declaration files on a resource with a new multi-file upload batch. */ +export async function persistDraftDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDraftDeclarationFieldNames(files); + assertDraftDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `draft_declaration_${index}`, + file, + }), + ), + ); +} + /** Require at least one transit permit file in the upload batch. */ export function assertTransitPermitFiles(files: Express.Multer.File[]): void { if (files.length === 0) { @@ -341,6 +389,22 @@ export function buildWorkflowFiles( }); }); + const extraDraftDeclarations = files + .filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDraftDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: draftDeclarationFileLabel(index), + uploadedBy: 'gl_et', + category: 'draft_declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + if (tradeDirection === 'IMPORT') { const extraTransit = files .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index da4b2f34b..a6c8c8d6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => { transitAssigneeRequested: jest.Mock; transitAssigneeAssigned: jest.Mock; }; + let transitAgentsService: { getAssignable: jest.Mock }; let service: ContractClearanceService; const cycle = (over: Record = {}) => ({ @@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => { transitAssigneeRequested: jest.fn(), transitAssigneeAssigned: jest.fn(), }; + transitAgentsService = { + getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }), + }; service = new ContractClearanceService( repo as never, contractsService as never, @@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => { {} as never, {} as never, notifier as never, + transitAgentsService as never, ); }); @@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => { cycle({ transitAssigneeRequestedAt: new Date() }), ); - await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1'); + await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'); + expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1'); const patch = repo.updateCycle.mock.calls[0][1]; expect(patch.transitAssigneeName).toBe('Ahmed Bourhan'); expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1'); @@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => { transitAssigneeName: 'Ahmed Bourhan', }), ); + transitAgentsService.getAssignable.mockResolvedValue({ + id: 'agent-2', + name: 'Fatouma Ali', + }); - await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1'); + await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1'); expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( expect.anything(), @@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => { ); }); - it('refuses an empty name', async () => { + it('refuses a suspended or out-of-window agent', async () => { repo.currentCycle.mockResolvedValue( cycle({ transitAssigneeRequestedAt: new Date() }), ); + transitAgentsService.getAssignable.mockRejectedValue( + new BadRequestException('suspended'), + ); await expect( - service.assignTransitAssignee('ctr-1', ' ', 'dj-1'), + service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'), ).rejects.toBeInstanceOf(BadRequestException); }); it('refuses before Ethiopia has asked', async () => { await expect( - service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'), + service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'), ).rejects.toThrow(/not requested/i); + expect(transitAgentsService.getAssignable).not.toHaveBeenCalled(); }); }); diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 7624800d8..4e2d40f55 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity { /** Why the file was replaced — shown on the document's version history. */ @Column({ name: "replace_reason", type: "text", nullable: true }) replaceReason!: string | null; + + /** + * Free-text label chosen by the uploader, when the document has no fixed slot + * (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every + * catalog-driven upload, whose label comes from its code. + */ + @Column({ name: "title", type: "varchar", length: 300, nullable: true }) + title!: string | null; + + /** Uploader's choice to share the document with the customer's portal. */ + @Column({ name: "visible_to_customer", type: "boolean", default: false }) + visibleToCustomer!: boolean; + + /** Who uploaded it — the only user allowed to edit or remove it afterwards. */ + @Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true }) + uploadedByUserId!: string | null; + + /** Uploader's display name, resolved once so lists need no IAM lookup. */ + @Column({ + name: "uploaded_by_name", + type: "varchar", + length: 200, + nullable: true, + }) + uploadedByName!: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index ea11e5fe8..bc446b508 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -15,6 +15,11 @@ export interface CreateFileInput { resource: string; code: string; file: Express.Multer.File; + /** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */ + title?: string | null; + visibleToCustomer?: boolean; + uploadedByUserId?: string | null; + uploadedByName?: string | null; } /** @@ -101,9 +106,27 @@ export class FilesService { url, size: file.size, mimeType: file.mimetype, + title: input.title ?? null, + visibleToCustomer: input.visibleToCustomer ?? false, + uploadedByUserId: input.uploadedByUserId ?? null, + uploadedByName: input.uploadedByName ?? null, }); } + /** + * Edit the uploader-authored metadata of a stored file (title, customer + * visibility). Bytes are untouched — callers replacing content upload a new + * record instead. + */ + async updateMeta( + id: string, + patch: { title?: string; visibleToCustomer?: boolean }, + ): Promise { + const updated = await this.filesRepository.update(id, patch); + if (!updated) throw new NotFoundException(`File ${id} not found`); + return updated; + } + /** * Replace the file stored under a resource + code (e.g. contract PDF). The * previous version is retired, not destroyed — pass `replacedBy` to record who diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index d7c45bb3c..6db55b66d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -327,6 +327,8 @@ export class LastMileService { */ async arrivalTrucksForBooking(bookingId: string): Promise< Array<{ + /** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -359,6 +361,7 @@ export class LastMileService { : []; const out: Array<{ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -386,6 +389,7 @@ export class LastMileService { } } out.push({ + lastMileId: lm.id, vehicleId: vehicle.id, truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, trailerPlateNumber: vehicle.trailerPlateNo || null, diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index a42dcd220..0c3b2aa51 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index 77d8b0df2..3883f8d99 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 3ad5b3650..11300cfee 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -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 { } /** - * 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 { + search?: string; + createdFrom?: string; + createdTo?: string; + }): SelectQueryBuilder { const qb = this.repository .createQueryBuilder('locomotive') .leftJoinAndSelect('locomotive.currentYard', 'currentYard') @@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository { 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 { qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); } - return qb.getMany(); + return qb; + } + + findForCoupling(opts: Parameters[0]): Promise { + return this.buildListQuery(opts).getMany(); } /** diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 46396893e..1da03072e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -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> { + 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; diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts index 59188a2a6..dfd9a1a91 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -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']) diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index cf2314156..259dd4c9f 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 855f8ec02..8c2989b87 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -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> { + return paginateArray(await this.findAll(filter), filter); + } + async findById(id: string): Promise { const route = await this.dataSource.getRepository(Route).findOne({ where: { id }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index b308a5921..a402e6237 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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(), + codesByTypeId: new Map(), + }), 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; + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index e010b4db9..41c8023f0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; -import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay } from './batch-window.util'; +import { + TrainSchedulingService, + effectiveWindowConfig, +} from './train-scheduling.service'; +import { eatDay, listConfigBookingWindows } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, @@ -87,6 +90,7 @@ import { OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; export type { Capacity } from './corridor-capacity.util'; @@ -166,6 +170,13 @@ export type BookingAllocationStatus = | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { + /** + * 0-based booking-window cycle this booking entered the pool in (derived from + * `fullyExecutedAt` against the schedule's window cycles). Ranking compares + * bookings within a cycle only — an earlier cycle always boards before a later + * one regardless of score. Null while the contract is still pending. + */ + windowCycleNo: number | null; fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; @@ -1320,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit { } } + const cycleOf = await this.windowCycleIndexer(s); const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { + windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null, id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment @@ -1619,6 +1632,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); + const stock = await this.stockLedgerFor(schedule, budget); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { await this.setWindow(scheduleId, "FULL"); @@ -1629,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit { // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill // must rank bulk bookings by their wagon-derived priority too. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); let armed = false; let preempted = false; @@ -1655,14 +1670,19 @@ export class BookingBatchService implements OnModuleInit { // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); + // Abstract room AND real wagons of a type this booking can ride — see + // fillRouteDayInternal for why both gates are needed. + const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); - // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. + // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + + `stocked=${stocked}`, ); - if (!budget.fits(need, leg)) { + if (!budget.fits(need, leg) || !stocked) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -1677,16 +1697,19 @@ export class BookingBatchService implements OnModuleInit { // Doesn't fit whole. A split-eligible import booking is offered the part // that fits in the remaining room (top-up path splits the boundary // booking, mirroring fillRouteDay); otherwise skip and try the next. - const cand: { id: string; budget: CorridorBudget; armed: boolean } = { - id: scheduleId, - budget, - armed, - }; - if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + const cand: { + id: string; + budget: CorridorBudget; + armed: boolean; + stock: WagonStockLedger; + } = { id: scheduleId, budget, armed, stock }; + if ( + await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds) + ) { armed = cand.armed; continue; } - continue; // skip a unit that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons/stock, try the next } } @@ -1704,6 +1727,8 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } budget.subtract(need, leg); + // Hold the physical wagons too — the next unit must not re-count them. + stock.consume(wagonTypeIds, need.wagons, leg); reservedThisPass += 1; } catch (err) { this.logger.error( @@ -1823,14 +1848,20 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); - // Live per-schedule corridor budget + arm/changed flags, in departure order. + // Live per-schedule corridor budget + physical wagon-type stock + arm/changed + // flags, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; + stock: WagonStockLedger; armed: boolean; changed: boolean; }> = []; + // The day group shares one booking window (route+day grouping), so any + // member's window grid stands for the pool's cycle derivation. + let cycleSchedule: TrainSchedule | null = null; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1841,10 +1872,12 @@ export class BookingBatchService implements OnModuleInit { ); continue; } + cycleSchedule ??= schedule; const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); - trains.push({ id, budget, armed: false, changed: false }); + const stock = await this.stockLedgerFor(schedule, budget); + trains.push({ id, budget, stock, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; @@ -1860,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit { // BULK bookings only get their real (wagon-derived) priority score now, at // batch time — stamp it and re-rank before the fill consumes the pool. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority( + pool, + cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined, + ); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); @@ -1884,12 +1920,20 @@ export class BookingBatchService implements OnModuleInit { const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => t.budget.legOf(booking.originYardId, booking.destinationYardId); + // Consolidated pairs share one wagon set; the primary's types stand for both. + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // First train (earliest departure) whose corridor carries this booking's - // leg and still fits it as-is. + // leg, still fits it as-is AND physically holds enough wagons of a type the + // booking can ride. Both gates matter: abstract room without the right + // wagon type is space the allocator can never turn into a loaded consist. let target = trains.find((t) => { const leg = legOn(t); - return leg != null && t.budget.fits(need, leg); + return ( + leg != null && + t.budget.fits(need, leg) && + this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) + ); }); // Per-unit trace: chosen train + each train's remaining room on this leg. @@ -1934,7 +1978,13 @@ export class BookingBatchService implements OnModuleInit { // already consumed most of the room). Consolidated pairs / government / // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. - const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + const offered = await this.maybeOfferPartial( + booking, + isPair, + trains, + need, + wagonTypeIds, + ); if (offered) { // A partial offer opens a real commercial pay window, same as reserve(). commercialReserved += 1; @@ -1964,6 +2014,9 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); + // Hold the physical wagons too, so the next unit in this pass sees them + // gone — otherwise two bookings both "fit" the same 16 NW5. + target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); target.changed = true; reservedThisPass += 1; } catch (err) { @@ -2027,14 +2080,32 @@ export class BookingBatchService implements OnModuleInit { private async maybeOfferPartial( booking: Booking, isPair: boolean, - candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + candidates: Array<{ + id: string; + budget: CorridorBudget; + armed: boolean; + stock?: WagonStockLedger; + }>, need: Capacity, + wagonTypeIds: string[] = [], ): Promise { 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 => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; @@ -2047,6 +2118,7 @@ export class BookingBatchService implements OnModuleInit { ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); + target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); target.c.armed = true; return true; } @@ -3204,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit { } } - /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ - private resortPoolByPriority(pool: Booking[]): void { + /** + * Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based + * booking-window cycle it arrived in: the last window whose open is at/before + * the timestamp (a timestamp in the doc-review/payment gap belongs to the + * cycle that just closed). The cycle grid comes from the schedule's frozen + * window-rule snapshot — the exact windows the cycle engine runs. + */ + private async windowCycleIndexer( + schedule: TrainSchedule, + ): Promise<(ts: Date | null | undefined) => number> { + if (!schedule.scheduledDepartureDate) return () => 0; + let starts: number[]; + try { + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const cfg = effectiveWindowConfig(schedule, liveCfg); + const windows = listConfigBookingWindows( + schedule.direction, + schedule.scheduledDepartureDate, + { + ...cfg, + reopenGapMinutes: + schedule.ruleReopenDelayMinutes ?? + cfg.docReviewMinutes + cfg.paymentWindowMinutes, + }, + ); + starts = windows.map((w) => w.start.getTime()); + } catch (err) { + // A failed cycle derivation must never block the batch — fall back to one + // flat cycle (pure priority order, the old behaviour). + this.logger.warn( + `Window-cycle derivation failed for schedule ${schedule.id}: ` + + `${(err as Error).message}`, + ); + return () => 0; + } + return (ts) => { + if (!ts) return 0; + const ms = ts.getTime(); + let idx = 0; + for (let i = 0; i < starts.length; i += 1) { + if (ms >= starts[i]) idx = i; + } + return idx; + }; + } + + /** + * Rank the batch pool: government first, then WINDOW CYCLE (bookings compete + * only within the cycle they arrived in — an earlier cycle's booking always + * outranks a later cycle's, whatever the scores), then priority score, then + * oldest. `cycleOf` comes from {@link windowCycleIndexer}. + */ + private resortPoolByPriority( + pool: Booking[], + cycleOf: (ts: Date | null | undefined) => number = () => 0, + ): void { pool.sort( (a, b) => Number(b.isGovernment) - Number(a.isGovernment) || + cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) || Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || (a.fullyExecutedAt?.getTime() ?? Infinity) - (b.fullyExecutedAt?.getTime() ?? Infinity) || @@ -3468,6 +3595,131 @@ export class BookingBatchService implements OnModuleInit { return dims.length ? dims : [fallback]; } + /** + * Physical wagon-type stock for one schedule, on the same corridor edges its + * {@link CorridorBudget} uses. Sourced from the scheduling service so the + * batch counts exactly the wagons the allocator will later plan against. + */ + private async stockLedgerFor( + schedule: TrainSchedule, + budget: CorridorBudget, + ): Promise { + 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; + byContainerTypeId: Map; + 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; + byContainerTypeId: Map; + }> { + 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(); + 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; + byContainerTypeId: Map; + }, + ): string[] { + if (booking.freightType === "BULK") { + const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id; + return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; + } + const ids = new Set(); + 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. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index db18d6804..b4520a43c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -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, }); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 8ad256a16..9dfea0781 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 8342eae47..0b8d4ad05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -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(); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index b4b3a64de..7f9586c3c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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 & Partial> >, ): 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 { return configured.length ? Math.min(...configured) : 0; } +function sumConfigured(values: Array): 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. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 15a542d31..599e1ed72 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, deriveTrainCapacityFromLocomotive, - minLocomotiveLimits, + combinedLocomotiveLimits, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -1300,9 +1300,9 @@ export class TrainSchedulingService { .slice() .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((link) => link.locomotiveId); - if (locomotiveIds.length < 2) { + if (locomotiveIds.length < 1) { throw new BadRequestException( - `Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`, + `Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`, ); } if (builtTrain.currentYardId !== route.originYardId) { @@ -1323,8 +1323,8 @@ export class TrainSchedulingService { } } else { locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } } @@ -1382,7 +1382,7 @@ export class TrainSchedulingService { builtTrain?.id ?? null, ); // Effective capacity is capped by the weakest locomotive in the set. - const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; + const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); // Every schedule starts with a CLOSED customer window; the window engine opens // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT @@ -1572,7 +1572,7 @@ export class TrainSchedulingService { }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); - const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; + const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined; const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); // Callers that add bookings without hand-picking container slots (the @@ -2754,11 +2754,13 @@ export class TrainSchedulingService { allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, + booking: allocation.booking, loadType: allocation.loadType ?? null, allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, containerNumbers: (allocation.containerItems ?? []) .map((item) => item.containerNumber) .filter(Boolean), + containerItems: allocation.containerItems ?? [], })), })), operation: await this.getImportDjiboutiOperation(schedule.id), @@ -2839,6 +2841,7 @@ export class TrainSchedulingService { return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); @@ -2847,6 +2850,7 @@ export class TrainSchedulingService { return ` ${wagonCells} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} ${esc(sealNumbers)} @@ -2861,6 +2865,18 @@ export class TrainSchedulingService { 0, ); + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { + (wagon.allocations ?? []).forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = item.bookingContainer?.containerSize; + if (size?.includes('40')) count40ft++; + else if (size?.includes('20')) count20ft++; + }); + }); + }); + return ` @@ -2910,6 +2926,9 @@ export class TrainSchedulingService {
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
Total loaded weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
@@ -2928,6 +2947,7 @@ export class TrainSchedulingService { Tare Weight Load Capacity Cargo Type + Company Container No Chassis No Seal No @@ -3010,6 +3030,19 @@ export class TrainSchedulingService { 0, ); const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + loadList.wagons.forEach((wagon) => { + wagon.allocations.forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = item.bookingContainer?.containerSize; + if (size?.includes('40')) count40ft++; + else if (size?.includes('20')) count20ft++; + }); + }); + }); + const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} @@ -3025,13 +3058,17 @@ export class TrainSchedulingService { ]; } return wagon.allocations.map( - (allocation) => ` + (allocation) => { + const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} + ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} - `, + `; + }, ); }) .join(''); @@ -3096,6 +3133,9 @@ export class TrainSchedulingService {
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -3115,6 +3155,7 @@ export class TrainSchedulingService { Seq Wagon Booking + Company Load Container numbers Weight T @@ -3971,36 +4012,12 @@ export class TrainSchedulingService { const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); const originYardId = dto.originStationId; - let stock: WagonStock; - if (builtTrainId) { - stock = await this.builtTrainStock(builtTrainId); - } else { - // Dynamic consist: a slot's physical wagon may ride from the train's origin - // OR already sit at the booking's own boarding yard and attach there — so - // the usable fleet is the union across the origin and every boarding yard. - const boardYardIds = [ - ...new Set( - [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), - ), - ]; - const fleetCountsByYard = await Promise.all( - boardYardIds.map((yardId) => - this.countFleetAvailability(yardId, targetScheduleId), - ), - ); - const remainingByTypeId = new Map(); - const codesByTypeId = new Map(); - for (const rows of fleetCountsByYard) { - for (const row of rows) { - remainingByTypeId.set( - row.wagonTypeId, - (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, - ); - codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); - } - } - stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; - } + const stock: WagonStock = await this.wagonStockForSchedule( + targetScheduleId, + originYardId, + bookings.map((b) => b.originYardId), + builtTrainId, + ); // Leg-aware stock: each booking consumes wagons only on the edges it rides, // so a ride-along on an empty leg never competes with cargo on a full one. @@ -4129,7 +4146,7 @@ export class TrainSchedulingService { // warning (it must arrive before dispatch), but a set too weak to pull the train // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); - const setLimits = minLocomotiveLimits(assignedLocomotives); + const setLimits = combinedLocomotiveLimits(assignedLocomotives); if (offYard) { warnings.push( `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, @@ -4799,6 +4816,51 @@ export class TrainSchedulingService { * type. This is the whole plannable pool for its schedules — the plan is * full when every consist wagon is allocated. */ + /** + * The physical wagons a schedule can actually plan against, by wagon type. + * + * A schedule built from a Train Builder train plans against ONLY that train's + * own consist. A legacy/dynamic-consist schedule plans against the boarding + * yards' loose pool: a slot's wagon may ride from the train's origin OR + * already sit at the booking's own boarding yard and attach there, so the + * usable fleet is the union across the origin and every boarding yard. + * + * Public because batch fill needs the SAME stock the allocator will later + * validate against — selecting a booking the allocator cannot place is how + * customers ended up paying for wagons that were never there. + */ + async wagonStockForSchedule( + scheduleId: string | undefined, + originYardId: string, + boardingYardIds: Array = [], + preloadedBuiltTrainId?: string | null, + ): Promise { + 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(); + const codesByTypeId = new Map(); + 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 { const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrainId }, @@ -5347,7 +5409,7 @@ export class TrainSchedulingService { * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling * philosophy: nothing serviceable is filtered out — staff see the status, * whether the train sits at the origin yard yet, and its future schedules. - * Trains with fewer than two locomotives are omitted (never schedulable). + * Trains with no locomotive at all are omitted (never schedulable). */ async getAvailableTrainsForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); @@ -5385,7 +5447,7 @@ export class TrainSchedulingService { const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)])); return trains - .filter((train) => (train.locomotives ?? []).length >= 2) + .filter((train) => (train.locomotives ?? []).length >= 1) .map((train) => { const wagons = train.wagons ?? []; return { @@ -5417,7 +5479,15 @@ export class TrainSchedulingService { totalLengthMeters: roundTons( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), - maxPullWeightTons: roundTons(Number(train.capacityTons)), + // Live from the coupled set — `capacity_tons` still holds the old + // single-locomotive figure on trains built before pull weight summed. + maxPullWeightTons: roundTons( + combinedLocomotiveLimits( + (train.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + )?.maxPullWeightTons ?? Number(train.capacityTons), + ), atOriginYard: train.currentYardId === route.originYardId, futureScheduleCount: futureCounts.get(train.id) ?? 0, }; @@ -5467,7 +5537,7 @@ export class TrainSchedulingService { ); const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); @@ -5590,7 +5660,7 @@ export class TrainSchedulingService { .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) .map((slot) => slot.physicalWagonId as string), ); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts new file mode 100644 index 000000000..47823cddd --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts new file mode 100644 index 000000000..0e4f6949d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -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(); + + constructor( + private readonly remainingByTypeId: Map, + 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(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; + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts index 4ad52a226..5c26d2475 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index c82cfd2eb..5f98ea608 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 5ba77fb10..54322c789 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts index 36562e970..0fab5ec5b 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts @@ -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[]; } diff --git a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts index 681b39a55..0c834379e 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 493e564e8..7a1ea5b64 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -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[]; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 19d631fbc..2b74c0253 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 31f108740..6aed88f07 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -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)), diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts new file mode 100644 index 000000000..be3810c0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +export class CreateTransitAgentDto { + @ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) + @IsString() + @MaxLength(150) + name!: string; + + @ApiProperty({ example: '2026-01-01' }) + @IsDateString() + validFrom!: string; + + @ApiProperty({ example: '2026-12-31' }) + @IsDateString() + validTo!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts new file mode 100644 index 000000000..7e18a93da --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTransitAgentDto } from './create-transit-agent.dto'; + +export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts new file mode 100644 index 000000000..6d0ef9158 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Djibouti transit officer GL Djibouti may assign against a shipment's + * transit-assignee handshake. Admin-managed so the roster and each officer's + * validity window arrive without a code change; `isActive` is the manual + * suspend/reactivate switch, independent of the validity window. + */ +@Entity({ schema: 'freight', name: 'transit_agents' }) +@Index(['isActive']) +export class TransitAgent extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'valid_from', type: 'date' }) + validFrom!: string; + + @Column({ name: 'valid_to', type: 'date' }) + validTo!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts new file mode 100644 index 000000000..4f4b90c7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + RuleEngineCreate, + RuleEngineDelete, + RuleEngineUpdate, + RuleEngineView, +} from '../../common/rule-engine-guards'; + +import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; +import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; +import { TransitAgentsService } from './transit-agents.service'; + +@ApiTags('transit-agents') +@Controller('transit-agents') +@ApiBearerAuth() +export class TransitAgentsController { + constructor(private readonly transitAgentsService: TransitAgentsService) {} + + @Get() + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'List transit agents' }) + findAll(@Query() query: Record) { + return this.transitAgentsService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : undefined, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + /** Active + currently valid officers — the transit-assignee assignment dropdown. */ + @Get('assignable') + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) + findAssignable() { + return this.transitAgentsService.findAssignable(); + } + + @Get(':id') + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'Get a transit agent by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.transitAgentsService.findById(id); + } + + @Post() + @RuleEngineCreate('transit-agents') + @ApiOperation({ summary: 'Create a transit agent' }) + create(@Body() dto: CreateTransitAgentDto) { + return this.transitAgentsService.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('transit-agents') + @ApiOperation({ summary: 'Update a transit agent' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { + return this.transitAgentsService.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('transit-agents') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a transit agent' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.transitAgentsService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts new file mode 100644 index 000000000..47e655e94 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgentsController } from './transit-agents.controller'; +import { TransitAgentsRepository } from './transit-agents.repository'; +import { TransitAgentsService } from './transit-agents.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TransitAgent])], + controllers: [TransitAgentsController], + providers: [TransitAgentsRepository, TransitAgentsService], + exports: [TransitAgentsRepository, TransitAgentsService], +}) +export class TransitAgentsModule {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts new file mode 100644 index 000000000..4418ad938 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -0,0 +1,28 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; + +import { TransitAgent } from './entities/transit-agent.entity'; + +@Injectable() +export class TransitAgentsRepository extends BaseRepository { + constructor( + @InjectRepository(TransitAgent) + repository: Repository, + ) { + super(repository); + } + + /** Active AND currently inside its validity window (today's date, server-side). */ + findAssignable(today: string): Promise { + return this.repository.find({ + where: { + isActive: true, + validFrom: LessThanOrEqual(today), + validTo: MoreThanOrEqual(today), + }, + order: { name: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts new file mode 100644 index 000000000..ec9c24e9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -0,0 +1,138 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; +import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; +import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgentsRepository } from './transit-agents.repository'; + +export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED'; + +export type TransitAgentView = TransitAgent & { + validityStatus: TransitAgentValidityStatus; +}; + +type TransitAgentListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */ +function todayISODate(): string { + return new Date().toISOString().slice(0, 10); +} + +function validityStatus(agent: Pick): TransitAgentValidityStatus { + const today = todayISODate(); + if (today < agent.validFrom) return 'NOT_STARTED'; + if (today > agent.validTo) return 'EXPIRED'; + return 'VALID'; +} + +function withValidityStatus(agent: TransitAgent): TransitAgentView { + return { ...agent, validityStatus: validityStatus(agent) }; +} + +@Injectable() +export class TransitAgentsService { + constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {} + + async findAll(filter: TransitAgentListFilter = {}): Promise<{ + data: TransitAgentView[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '') + ? (filter.sortBy as keyof TransitAgent) + : 'name'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.transitAgentsRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data: data.map(withValidityStatus), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** Active and currently inside its validity window — the DJ assignment dropdown. */ + async findAssignable(): Promise { + return this.transitAgentsRepository.findAssignable(todayISODate()); + } + + async findById(id: string): Promise { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + return withValidityStatus(agent); + } + + /** Used by the assignment flow — rejects a suspended or out-of-window officer. */ + async getAssignable(id: string): Promise { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new BadRequestException('Selected transit officer was not found.'); + } + if (!agent.isActive) { + throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`); + } + if (validityStatus(agent) !== 'VALID') { + throw new BadRequestException( + `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, + ); + } + return agent; + } + + async create(dto: CreateTransitAgentDto): Promise { + if (dto.validTo < dto.validFrom) { + throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + } + const agent = await this.transitAgentsRepository.create({ + name: dto.name.trim(), + validFrom: dto.validFrom, + validTo: dto.validTo, + isActive: dto.isActive ?? true, + }); + return withValidityStatus(agent); + } + + async update(id: string, dto: UpdateTransitAgentDto): Promise { + const current = await this.findById(id); + const nextValidFrom = dto.validFrom ?? current.validFrom; + const nextValidTo = dto.validTo ?? current.validTo; + if (nextValidTo < nextValidFrom) { + throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + } + + const updated = await this.transitAgentsRepository.update(id, { + ...dto, + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + return withValidityStatus(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.transitAgentsRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index c7eecfc02..328a6eaa8 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index ecf512aac..205d86450 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -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'); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 79a74f4ce..8d4159726 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index bac10299d..c792057d8 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 99c28469f..f51846e2d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -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 { + /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ + private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder { 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> { + return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index c0b3cc060..7cd47f203 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3679,6 +3679,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; }> > { const rows: Array<{ @@ -3696,6 +3697,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; delivered: boolean; + inspectionStatus: string | null; }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, @@ -3710,7 +3712,8 @@ export class WarehouseInventoryService { b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", - COALESCE(inv.status = 'DELIVERED', false) AS delivered + COALESCE(inv.status = 'DELIVERED', false) AS delivered, + inv.inspection_status AS "inspectionStatus" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -3762,6 +3765,7 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + inspectionStatus: r.inspectionStatus, handoverSigned, })); } diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 6b8529225..9f10c3e0e 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -164,20 +164,25 @@ export class EdrOrgSeeder { manager: EntityManager, applicationId: string, ) { - const permissionRepository = manager.getRepository(Permission); - - // Upsert by key so reruns are idempotent; applicationId ties every - // permission to the EDR Freight application (also backfills rows that - // were previously seeded without the relation). - await permissionRepository.upsert( - EDR_FREIGHT_PERMISSIONS.map((permission) => ({ - id: permission.id, - key: permission.key, - name: { ...permission.name }, - applicationId, - })), - { conflictPaths: { key: true } }, - ); + // iam.permissions has TWO unique columns (PK id, UQ key) but ON CONFLICT + // can only target one. Seeding a hand-minted id that some older/retired key + // already owns in an environment slips past ON CONFLICT (key) and dies on + // the PK. The key is the identity every consumer resolves by (positions + // seeder maps key -> id at runtime), so ids are left to the column default + // and never sent — no id can collide. + await manager + .createQueryBuilder() + .insert() + .into(Permission) + .values( + EDR_FREIGHT_PERMISSIONS.map((permission) => ({ + key: permission.key, + name: { ...permission.name }, + applicationId, + })), + ) + .orUpdate(["name", "application_id"], ["key"]) + .execute(); this.logger.log( `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts new file mode 100644 index 000000000..caab85c3e --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -0,0 +1,13 @@ +import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; + +describe('EDR_FREIGHT_PERMISSIONS', () => { + // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE + // statement — a duplicated key there is a Postgres 21000 at boot, not a + // silent no-op. + it('has no duplicate keys', () => { + const keys = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key); + const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))]; + + expect(duplicates).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 2924169eb..9190316cd 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -22,6 +22,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ // Keep new slugs at the END: ruleEngineCrudId derives ids from list index, // so a mid-list insert would shift ids already seeded for later slugs. 'truck-types', + 'transit-agents', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -103,10 +104,17 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ // Each has its own permission so the two desks are genuinely separate people. perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), + // Freeze/unfreeze a signed contract. One key covers both directions — whoever + // may suspend must be able to lift it again. + perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'), ]; -// Existing per-slug view ids are kept as-is: position-type grants reference -// them by id, so re-minting would orphan those rows. +// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and +// lets the column default mint the uuid — so these are kept only as a record of +// which ids each environment already holds. A hand-picked id must still never +// be recycled from a retired key: `edr_freight_app:rule_engine:truck_types:manage` +// owned …001b, and reusing it for transit-agents crashed boot with a PK 23505 +// on every environment that still had the retired row. const RULE_ENGINE_VIEW_IDS: Record = { 'cargo-types': 'b2000001-0001-4000-8000-000000000001', 'container-types': 'b2000001-0001-4000-8000-000000000003', @@ -120,6 +128,7 @@ const RULE_ENGINE_VIEW_IDS: Record = { rates: 'b2000001-0001-4000-8000-000000000011', 'approval-rules': 'b2000001-0001-4000-8000-000000000013', 'yard-distances': 'b2000001-0001-4000-8000-000000000018', + 'transit-agents': 'b2000003-0001-4000-8000-000000000001', }; // CRUD replaces the retired coarse `:manage`. New ids live in a fresh block @@ -133,7 +142,7 @@ const ruleEngineCrudId = ( ): string => { const n = RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 + - RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + + RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + 1; // 1..36 return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`; }; @@ -447,6 +456,7 @@ export const FREIGHT_PERMS = { clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', + suspend: 'edr_freight_app:contracts:suspend', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -897,6 +907,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), + FREIGHT_PERMS.contracts.suspend, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 5e2e48f32..77e67f210 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -168,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , 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", diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 82ae8e808..c59144706 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -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; }); diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx new file mode 100644 index 000000000..1a77632ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; +import { Coins, Truck } from "lucide-react"; + +import { api } from "@/services/api"; +import { warehouseService } from "@/services/warehouse.service"; +import { lastMileService } from "@/services/last-mile.service"; +import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; +import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + +const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—"); + +function inspectionLabel(status: string | null | undefined): { text: string; color: string } { + if (!status) return { text: "Pending", color: "gray" }; + if (status === "PASSED") return { text: "Passed", color: "edr-green" }; + if (status === "FAILED") return { text: "Failed", color: "red" }; + return { text: status, color: "gray" }; +} + +interface TruckRow { + key: string; + plate: string; + driver: string | null; + truckType: string | null; + containers: string[]; + warehouseArrived: string | null; + warehouseDeparted: string | null; + destinationArrived: string | null; + returned: string | null; + detentionOpen: boolean; + detentionDays: number | null; + detentionAmount: number | null; + hasDetentionRule: boolean; + inspection: { text: string; color: string }; +} + +/** + * Every truck tied to a booking's last mile — EDR-dispatched or customer + * self-haul (a booking only ever uses one), each with its own warehouse-gate + * and destination-detention clocks, plus the booking's cargo-side cost totals + * (storage/demurrage/double handling — billed per row internally, always + * shown here as one booking-level total). Detention stays EDR-only; customer + * self-haul rows show "—" since EDR only bills detention on its own fleet. + */ +export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { + const [feeModalOpen, setFeeModalOpen] = useState(false); + const [detentionModalOpen, setDetentionModalOpen] = useState(false); + + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const inventoryItems = inventoryQuery.data ?? []; + const latestInventory = inventoryItems[0] ?? null; + + const edrTrucksQuery = useQuery({ + queryKey: ["booking-edr-trucks", bookingId], + queryFn: () => warehouseService.getLastMileTrucks(bookingId), + }); + const edrTrucks = edrTrucksQuery.data ?? []; + + const customerTrucksQuery = useQuery({ + queryKey: ["booking-customer-trucks", bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId), + enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0, + }); + const customerTrucks = customerTrucksQuery.data ?? []; + + const mode: "EDR" | "CUSTOMER" | "NONE" = + edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE"; + + const containerItemsQuery = useQuery({ + queryKey: ["booking-container-items-for-trucks", bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId), + }); + const inspectionByContainer = new Map( + (containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]), + ); + + const lastMileId = edrTrucks[0]?.lastMileId ?? null; + + const detentionPreviewQuery = useQuery({ + queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + const detentionPreview = detentionPreviewQuery.data; + const detentionByVehicle = new Map( + (detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), + ); + + const lastMileRecordQuery = useQuery({ + queryKey: ["last-mile-record-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + + // Booking-level cost strip: same per-row fee preview the accrual dashboard + // and FeePreviewModal already use, summed across every inventory row on + // this booking rather than duplicated per row. + const feeQueries = useQueries({ + queries: inventoryItems.map((item) => + api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }), + ), + }); + const allFees = feeQueries.flatMap((q) => q.data ?? []); + const feeCurrency = allFees[0]?.currency ?? "USD"; + const sumByType = (type: string) => + allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); + + const rows: TruckRow[] = useMemo(() => { + if (mode === "EDR") { + return edrTrucks.map((t) => { + const g = detentionByVehicle.get(t.vehicleId); + return { + key: t.vehicleId, + plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", + driver: t.driverName, + truckType: t.truckType, + containers: t.containerNumber ? [t.containerNumber] : [], + warehouseArrived: t.arrivedAt, + warehouseDeparted: t.departedAt, + destinationArrived: g?.startDate ?? null, + returned: g?.endIsOpen ? null : g?.endDate ?? null, + detentionOpen: Boolean(g?.endIsOpen), + detentionDays: g?.chargeableDays ?? null, + detentionAmount: g?.amount ?? null, + hasDetentionRule: Boolean(g?.ruleId), + inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined), + }; + }); + } + if (mode === "CUSTOMER") { + return customerTrucks.map((t) => { + const containers = (t.containers ?? []).map((c) => c.containerNumber); + const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null)); + const inspection = + containers.length === 0 + ? inspectionLabel(undefined) + : statuses.size > 1 + ? { text: "Mixed", color: "yellow" } + : inspectionLabel([...statuses][0]); + return { + key: t.id, + plate: t.plateNumber, + driver: t.driverName, + truckType: t.truckType, + containers, + warehouseArrived: t.arrivedAt ?? null, + warehouseDeparted: t.departedAt ?? null, + destinationArrived: null, + returned: null, + detentionOpen: false, + detentionDays: null, + detentionAmount: null, + hasDetentionRule: false, + inspection, + }; + }); + } + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]); + + if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) { + return ( +
+ + + Loading trucks… + +
+ ); + } + + return ( + + setFeeModalOpen(true)}> + View breakdown + + ) + } + > + + + + + + + + setDetentionModalOpen(true)}> + Detention times + + ) + } + > + {rows.length === 0 ? ( + + No trucks assigned to this booking's last mile yet. + + ) : ( + + + + + Plate + Driver + Type + Container(s) + Wh. arrived + Wh. departed + Dest. arrived + Returned + Detention + Inspection + + + + {rows.map((r) => ( + + {r.plate} + {r.driver ?? "—"} + {r.truckType ?? "—"} + {r.containers.length ? r.containers.join(", ") : "—"} + {fmt(r.warehouseArrived)} + {fmt(r.warehouseDeparted)} + {fmt(r.destinationArrived)} + + {r.detentionOpen ? ( + + still out + + ) : ( + fmt(r.returned) + )} + + + {mode !== "EDR" || r.detentionDays == null ? ( + "—" + ) : ( + <> + {r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")} + {!r.hasDetentionRule && ( + + {" "} + · no rule + + )} + + )} + + + + {r.inspection.text} + + + + ))} + +
+
+ )} +
+ + setFeeModalOpen(false)} + inventoryId={latestInventory?.id ?? null} + /> + {mode === "EDR" && ( + setDetentionModalOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index f4a677991..ecbb0488e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -2,6 +2,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; +export * from "./BookingTrucksPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx index c9290bb9b..610fb7e3d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx @@ -1,12 +1,15 @@ import type { ReactNode } from "react"; import { Badge, Stack, Tabs, Text } from "@mantine/core"; -import { AlertTriangle, FileText, ShieldAlert } from "lucide-react"; +import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard"; import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; +import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; export interface ClearanceOpsTabsProps { bookingId: string | undefined; @@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps { /** Phased customs workflow files — enables the Uploaded documents tab. */ workflowFiles?: Freight.ClearanceWorkflowFile[]; showWorkflowFilesTab?: boolean; + /** + * Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange + * belongs on this page. Undefined hides the tab; it is also hidden from staff + * who hold neither desk's clearance-actions permission. + */ + exchangeEntityId?: string; tradeDirection?: string; onViewFile?: (file: { name: string; url: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void; @@ -40,6 +49,7 @@ export function ClearanceOpsTabs({ clearanceTab, workflowFiles = [], showWorkflowFilesTab = false, + exchangeEntityId, tradeDirection = "IMPORT", onViewFile, onDownloadFile, @@ -53,7 +63,12 @@ export function ClearanceOpsTabs({ return true; }).length; const showDocuments = showWorkflowFilesTab && Boolean(onViewFile); - const hasTabs = (showOpsTabs && hasOps) || showDocuments; + const { user } = useAuth(); + const showExchange = + Boolean(exchangeEntityId) && + (hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || + hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)); + const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange; if (!hasTabs) { return <>{clearanceTab}; @@ -78,6 +93,11 @@ export function ClearanceOpsTabs({ Uploaded documents ) : null} + {showExchange ? ( + }> + Document exchange + + ) : null} {showOpsTabs && riskMs ? ( }> Risk assignment @@ -103,6 +123,12 @@ export function ClearanceOpsTabs({ ) : null} + {showExchange ? ( + + + + ) : null} + {showOpsTabs && riskMs && bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx index 8428edf52..8ff9dffcc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx @@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record< string > = { declaration: "Declaration", + draft_declaration: "Draft declaration", duty: "Duty & taxes", transit: "Transit", djibouti: "Djibouti", }; const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [ + "draft_declaration", "declaration", "duty", "transit", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index fcd04fb58..d2a245f24 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -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 ( + + + + This contract is frozen. New bookings are blocked and its existing + shipments cannot progress. + {contract.statusBeforeSuspension + ? ` Lifting the suspension returns it to ${contract.statusBeforeSuspension}.` + : ""} + + {contract.latestSuspensionNote && ( + + Reason: {contract.latestSuspensionNote} + + )} + {maySuspend ? ( + + ) : ( + + You do not have permission to lift a suspension. + + )} + + + setResumeOpen(false)} + title="Lift suspension?" + centered + > + + + Contract {contract.reference} will return to{" "} + {contract.statusBeforeSuspension ?? "CONTRACT_ACTIVE"} and + the customer will be notified. Bookings on it resume immediately. + +