diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 64df33f35..df82299a8 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"; @@ -101,6 +102,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { LoggerMiddleware } from "./logger.middleware"; +import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; @Module({ imports: [ @@ -240,6 +242,7 @@ import { LoggerMiddleware } from "./logger.middleware"; // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, + LoginAudienceMiddleware, ], }) export class AppModule implements OnApplicationBootstrap { @@ -328,5 +331,9 @@ export class AppModule implements OnApplicationBootstrap { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes("*"); + consumer.apply(LoginAudienceMiddleware).forRoutes( + { path: "auth/login", method: RequestMethod.POST }, + { path: "auth/mfa-verify", method: RequestMethod.POST }, + ); } } 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/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..5e8b6aa0e 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. */ @@ -362,6 +392,21 @@ export class BookingLifecycleNotifierService { ); } + /** + * The customer disputed the advised duty & tax. This goes to STAFF, not the + * customer: GL Ethiopia is the one who has to re-advise, and the clearance + * page is where they do it. + */ + dutyDisputed(b: Booking, note: string): void { + const msg = + `The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` + + `"${note}". Review and re-advise the amount on the clearance page.`; + this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { const label = 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..5b6bddbff 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 names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + }) + async assignBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('assignee') assignee: string, + ) { + const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @@ -872,6 +900,24 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/duty/dispute') + @ApiOperation({ + summary: + 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', + }) + async disputeBookingDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.disputeDuty( + id, + note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) 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..75007326e 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 disputed the advised duty & tax and asked GL Ethiopia to + * correct it. One row per round — the advice/dispute loop can repeat. + */ + 'DUTY_DISPUTE', +] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; @Entity({ schema: 'freight', name: 'booking_review_note' }) 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..3d8776e80 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,6 +101,8 @@ function makeService(overrides?: { documentQueried: jest.fn(), dutySlipUploadedToStaff: jest.fn(), clearanceDocsUploadedToStaff: jest.fn(), + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), } as never, // notifier ); @@ -186,6 +198,82 @@ describe('BookingClearanceService', () => { service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects an import declaration before Djibouti names the transit officer', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toThrow(/Request a transit assignee/i); + expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled(); + }); + + it('lets the export declaration through without a transit assignee', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalExportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await service.uploadDeclaration('b-export', [ + { fieldname: 'decl' } as Express.Multer.File, + ]); + + expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled(); + }); + }); + + describe('transit assignee handshake', () => { + it('refuses an assignment GL Ethiopia never asked for', async () => { + const { service } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.assignTransitAssignee('b-general', 'Ahmed Bourhan'), + ).rejects.toThrow(/has not requested a transit assignee/i); + }); + + it('stamps the ask and then the name', async () => { + const { service, bookingsRepository } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeName: null, + } as Booking, + }); + + await service.requestTransitAssignee('b-general', ' night shift '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeRequestedAt: expect.any(Date), + transitAssigneeRequestNote: 'night shift', + }), + ); + + await service.assignTransitAssignee('b-general', ' Ahmed Bourhan '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeName: 'Ahmed Bourhan', + transitAssigneeAssignedAt: expect.any(Date), + }), + ); + }); }); describe('uploadReleaseOrder', () => { 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..bfb44852a 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 @@ -71,12 +71,32 @@ export interface BookingClearanceView { roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; + /** + * Pre-declaration handshake with GL Djibouti: who handles this shipment in + * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot + * file the import customs declaration before it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * The customer's open objection to the advised duty. Present only until GL + * re-advises; `rounds` counts how many times it has been sent back. + */ + dutyDispute?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; @@ -113,13 +133,10 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, ) {} - private async assertPhasedGeneralCustoms(booking: Booking): Promise { + 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 +144,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 +231,7 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const dutyDispute = await this.buildDutyDispute(bookingId, milestones); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', @@ -272,7 +290,18 @@ export class BookingClearanceService { : null, operationReady: boundary, preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + transitAssignee: { + requestedAt: booking.transitAssigneeRequestedAt + ? booking.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: booking.transitAssigneeRequestNote ?? null, + name: booking.transitAssigneeName ?? null, + assignedAt: booking.transitAssigneeAssignedAt + ? booking.transitAssigneeAssignedAt.toISOString() + : null, + }, dutyAdvice, + dutyDispute, workflowFiles, t1, train, @@ -330,6 +359,22 @@ export class BookingClearanceService { }; } + private async buildDutyDispute( + bookingId: string, + milestones: ClearanceMilestone[], + ): Promise { + const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); + if (!advised || advised.status === 'COMPLETED') return null; + const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE'); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.note, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; @@ -352,14 +397,60 @@ export class BookingClearanceService { ); } - isPhasedGeneralCustomsBooking(booking: Booking): boolean { + /** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */ + isPhasedCustomsBooking(booking: Booking): boolean { return ( - Boolean(booking.customsClearingEnabled) && - booking.contractKind === 'GENERAL' && - Boolean(booking.contractId) + Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId) ); } + /** + * GL Ethiopia asks Djibouti to name the officer who will handle this shipment + * in transit. The import declaration is gated on the answer, so this is the + * first thing ET does once the customer documents are approved. Re-requesting + * is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + bookingId: string, + note: string | undefined, + ): Promise { + const booking = await this.loadBooking(bookingId); + + await this.bookingsRepository.update(bookingId, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestNote: note?.trim() || null, + } as never); + + this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null); + return this.bookingsService.findById(bookingId); + } + + /** + * GL Djibouti names the transit officer — free text, because the person is not + * a platform user. Answering unblocks the declaration for Ethiopia. A later + * call overwrites the name (reassignment) and re-notifies. + */ + async assignTransitAssignee(bookingId: string, assignee: string): Promise { + const booking = await this.loadBooking(bookingId); + if (!assignee?.trim()) { + throw new BadRequestException('Name the officer who will handle the transit.'); + } + if (!booking.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this shipment yet.', + ); + } + + const previous = booking.transitAssigneeName ?? null; + await this.bookingsRepository.update(bookingId, { + transitAssigneeName: assignee.trim(), + transitAssigneeAssignedAt: new Date(), + } as never); + + this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous); + return this.bookingsService.findById(bookingId); + } + async uploadDeclaration( bookingId: string, files: Express.Multer.File[], @@ -373,6 +464,16 @@ export class BookingClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // Import only: the declaration is filed against whoever physically handles + // the shipment in Djibouti, so that name must be in first. Exports have no + // such handshake — their Djibouti steps come after the declaration. + if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) { + throw new BadRequestException( + booking.transitAssigneeRequestedAt + ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' + : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', + ); + } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { @@ -460,6 +561,65 @@ export class BookingClearanceService { return this.bookingsService.findById(bookingId); } + /** + * The customer disagrees with the advised duty & tax on this booking and asks + * GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so + * the Duty & tax step becomes actionable again on the GL clearance page, with + * the customer's message shown beside it. GL re-advises (same endpoint as the + * first time), which closes the dispute — the loop may run as many rounds as + * it takes. + */ + async disputeDuty( + bookingId: string, + note: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty applies only to import bookings.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what is wrong with the advised amount so GL can correct it.', + ); + } + if (!booking.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { + throw new BadRequestException( + 'There is no advised duty amount to dispute yet.', + ); + } + // Once the slip is in, the money is paid — a dispute then is a refund + // conversation, not a re-advice. + if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', + ); + } + + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + 'DUTY_DISPUTE', + userId, + ); + // Back to GL: reopening the milestone is what re-arms the Duty & tax step + // (the stepper picks its active step from milestone completion). + await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.dutyDisputed(updated, note.trim()); + return updated; + } + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -720,7 +880,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } @@ -733,7 +893,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if ( belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts new file mode 100644 index 000000000..4a3904182 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts @@ -0,0 +1,162 @@ +import { BadRequestException } from '@nestjs/common'; + +import { BookingClearanceService } from './booking-clearance.service'; +import type { Booking } from '../bookings/entities/booking.entity'; + +/** + * The duty advice → dispute → re-advice loop, at the booking level. GL + * Ethiopia advises an amount; the customer either pays it or sends it back + * with a reason. Sending it back reopens the advice milestone — that is what + * puts the Duty & tax step back in GL's hands — and the round can repeat + * until the amount is agreed. + */ +describe('BookingClearanceService — duty dispute', () => { + const booking = (over: Partial = {}): Booking => + ({ + id: 'bk-1', + reference: 'BKG-2026-00042', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractId: 'ctr-1', + dutyRequired: true, + ...over, + }) as Booking; + + const milestone = (code: string, status: string) => + ({ milestoneCode: code, status }) as never; + + let repo: { + createReviewNote: jest.Mock; + findReviewNotes: jest.Mock; + update: jest.Mock; + }; + let bookingsService: { findById: jest.Mock }; + let workflowService: { listMilestonesForBooking: jest.Mock }; + let milestoneService: { reopenForBooking: jest.Mock }; + let notifier: { dutyDisputed: jest.Mock }; + let service: BookingClearanceService; + + const build = (milestones: unknown[]) => { + workflowService.listMilestonesForBooking.mockResolvedValue(milestones); + }; + + beforeEach(() => { + repo = { + createReviewNote: jest.fn().mockResolvedValue(undefined), + findReviewNotes: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + bookingsService = { findById: jest.fn().mockResolvedValue(booking()) }; + workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) }; + milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) }; + notifier = { dutyDisputed: jest.fn() }; + + service = new BookingClearanceService( + repo as never, + bookingsService as never, + {} as never, // filesService + {} as never, // fileUploadSettingsService + workflowService as never, + milestoneService as never, + {} as never, // dropdownSettingsService + {} as never, // glOperationsService + notifier as never, + ); + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'PENDING'), + ]); + }); + + it('records the objection and hands the step back to GL', async () => { + await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'bk-1', + 'Declared value is wrong', + 'DUTY_DISPUTE', + 'user-1', + ); + // Reopening the advice milestone is what re-arms the Duty & tax step. + expect(milestoneService.reopenForBooking).toHaveBeenCalledWith( + 'bk-1', + 'DUTY_TAXES_ADVISED', + ); + expect(repo.update).toHaveBeenCalledWith('bk-1', { + clearanceCurrentPhase: 'GL_ET_OUTPUT', + }); + }); + + it('tells GL Ethiopia, not the customer', async () => { + await service.disputeDuty('bk-1', 'Too high', 'user-1'); + expect(notifier.dutyDisputed).toHaveBeenCalledWith( + expect.objectContaining({ id: 'bk-1' }), + 'Too high', + ); + }); + + it('requires a reason — GL cannot correct an unexplained objection', async () => { + await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(milestoneService.reopenForBooking).not.toHaveBeenCalled(); + }); + + it('refuses when nothing has been advised yet', async () => { + build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]); + await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( + /no advised duty amount/i, + ); + }); + + it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => { + build([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + milestone('DUTY_TAX_PAID', 'COMPLETED'), + ]); + await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( + /already been submitted/i, + ); + }); + + it('refuses when duty was never required for this clearance', async () => { + bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false })); + await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( + /not required/i, + ); + }); + + describe('the view', () => { + const buildDispute = (milestones: unknown[]) => + ( + service as unknown as { + buildDutyDispute: (id: string, m: unknown[]) => Promise; + } + ).buildDutyDispute('bk-1', milestones); + + it('shows the objection while GL still owes a corrected advice', async () => { + repo.findReviewNotes.mockResolvedValue([ + { note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') }, + { note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'PENDING'), + ]); + + expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 }); + }); + + it('clears itself once GL re-advises', async () => { + repo.findReviewNotes.mockResolvedValue([ + { note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, + ]); + + const dispute = await buildDispute([ + milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), + ]); + + expect(dispute).toBeNull(); + }); + }); +}); 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/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..31fc1f252 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 @@ -34,7 +34,7 @@ import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -1060,46 +1060,6 @@ export class ContractClearanceService { }); } - /** - * Operations queue: self-clearance (Path A) contracts awaiting Operations - * review of the customer's own clearance documents. - */ - /** - * Statuses a non-customs contract passes through around Operations - * clearance review — the set a caller may narrow {@link opsQueue} to. - */ - private static readonly OPS_CLEARANCE_STATUSES = [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - 'FULLY_EXECUTED', - 'CONTRACT_ACTIVE', - 'ACTIVE_SHIPMENT_IN_PROGRESS', - 'CONTRACT_CLOSED', - 'CANCELLED', - ]; - - async opsQueue(filter: FilterContractDto): Promise { - // 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 { @@ -1693,80 +1653,4 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } - /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ - async etQueue(filter: FilterContractDto): Promise { - 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-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..ab330ae9c 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' }) @@ -924,31 +998,8 @@ export class ContractsController { return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); } - @Get('clearance/et-queue') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) - etClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.etQueue(filter); - } - - @Get('clearance/dj-queue') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) - djClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.djQueue(filter); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── - @Get('clearance/ops-queue') - @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) - @ApiOperation({ - summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review', - }) - opsClearanceQueue(@Query() filter: FilterContractDto) { - return this.clearanceService.opsQueue(filter); - } - @Post(':id/clearance/ops-review') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ @@ -1017,7 +1068,7 @@ export class ContractsController { @Post(':id/bookings/initiate') @ApiOperation({ summary: - 'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).', + 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) initiateBooking( @Param('id', ParseUUIDPipe) id: string, @@ -1335,7 +1386,7 @@ export class ContractsController { ) { const file = (files ?? [])[0]; const booking = await this.bookingsService.findById(bookingId); - if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) { return this.bookingClearanceService.uploadDutySlip(bookingId, file); } return this.glOperationsService.uploadDutySlip(bookingId, file); 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/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/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..586ef2f7c 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 @@ -87,6 +87,7 @@ import { OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; export type { Capacity } from './corridor-capacity.util'; @@ -1619,6 +1620,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); + const stock = await this.stockLedgerFor(schedule, budget); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { await this.setWindow(scheduleId, "FULL"); @@ -1655,14 +1658,19 @@ export class BookingBatchService implements OnModuleInit { // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); + // Abstract room AND real wagons of a type this booking can ride — see + // fillRouteDayInternal for why both gates are needed. + const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); - // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. + // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + + `stocked=${stocked}`, ); - if (!budget.fits(need, leg)) { + if (!budget.fits(need, leg) || !stocked) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -1677,16 +1685,19 @@ export class BookingBatchService implements OnModuleInit { // Doesn't fit whole. A split-eligible import booking is offered the part // that fits in the remaining room (top-up path splits the boundary // booking, mirroring fillRouteDay); otherwise skip and try the next. - const cand: { id: string; budget: CorridorBudget; armed: boolean } = { - id: scheduleId, - budget, - armed, - }; - if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + const cand: { + id: string; + budget: CorridorBudget; + armed: boolean; + stock: WagonStockLedger; + } = { id: scheduleId, budget, armed, stock }; + if ( + await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds) + ) { armed = cand.armed; continue; } - continue; // skip a unit that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons/stock, try the next } } @@ -1704,6 +1715,8 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } budget.subtract(need, leg); + // Hold the physical wagons too — the next unit must not re-count them. + stock.consume(wagonTypeIds, need.wagons, leg); reservedThisPass += 1; } catch (err) { this.logger.error( @@ -1823,11 +1836,14 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); - // Live per-schedule corridor budget + arm/changed flags, in departure order. + // Live per-schedule corridor budget + physical wagon-type stock + arm/changed + // flags, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; + stock: WagonStockLedger; armed: boolean; changed: boolean; }> = []; @@ -1844,7 +1860,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); - trains.push({ id, budget, armed: false, changed: false }); + const stock = await this.stockLedgerFor(schedule, budget); + trains.push({ id, budget, stock, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; @@ -1884,12 +1901,20 @@ export class BookingBatchService implements OnModuleInit { const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => t.budget.legOf(booking.originYardId, booking.destinationYardId); + // Consolidated pairs share one wagon set; the primary's types stand for both. + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // First train (earliest departure) whose corridor carries this booking's - // leg and still fits it as-is. + // leg, still fits it as-is AND physically holds enough wagons of a type the + // booking can ride. Both gates matter: abstract room without the right + // wagon type is space the allocator can never turn into a loaded consist. let target = trains.find((t) => { const leg = legOn(t); - return leg != null && t.budget.fits(need, leg); + return ( + leg != null && + t.budget.fits(need, leg) && + this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) + ); }); // Per-unit trace: chosen train + each train's remaining room on this leg. @@ -1934,7 +1959,13 @@ export class BookingBatchService implements OnModuleInit { // already consumed most of the room). Consolidated pairs / government / // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. - const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + const offered = await this.maybeOfferPartial( + booking, + isPair, + trains, + need, + wagonTypeIds, + ); if (offered) { // A partial offer opens a real commercial pay window, same as reserve(). commercialReserved += 1; @@ -1964,6 +1995,9 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); + // Hold the physical wagons too, so the next unit in this pass sees them + // gone — otherwise two bookings both "fit" the same 16 NW5. + target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); target.changed = true; reservedThisPass += 1; } catch (err) { @@ -2027,14 +2061,32 @@ export class BookingBatchService implements OnModuleInit { private async maybeOfferPartial( booking: Booking, isPair: boolean, - candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + candidates: Array<{ + id: string; + budget: CorridorBudget; + armed: boolean; + stock?: WagonStockLedger; + }>, need: Capacity, + wagonTypeIds: string[] = [], ): Promise { 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 +2099,7 @@ export class BookingBatchService implements OnModuleInit { ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); + target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); target.c.armed = true; return true; } @@ -3468,6 +3521,131 @@ export class BookingBatchService implements OnModuleInit { return dims.length ? dims : [fallback]; } + /** + * Physical wagon-type stock for one schedule, on the same corridor edges its + * {@link CorridorBudget} uses. Sourced from the scheduling service so the + * batch counts exactly the wagons the allocator will later plan against. + */ + private async stockLedgerFor( + schedule: TrainSchedule, + budget: CorridorBudget, + ): Promise { + 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..418338257 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 @@ -3971,36 +3971,12 @@ export class TrainSchedulingService { const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); const originYardId = dto.originStationId; - let stock: WagonStock; - if (builtTrainId) { - stock = await this.builtTrainStock(builtTrainId); - } else { - // Dynamic consist: a slot's physical wagon may ride from the train's origin - // OR already sit at the booking's own boarding yard and attach there — so - // the usable fleet is the union across the origin and every boarding yard. - const boardYardIds = [ - ...new Set( - [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), - ), - ]; - const fleetCountsByYard = await Promise.all( - boardYardIds.map((yardId) => - this.countFleetAvailability(yardId, targetScheduleId), - ), - ); - const remainingByTypeId = new Map(); - 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 +4105,7 @@ export class TrainSchedulingService { // warning (it must arrive before dispatch), but a set too weak to pull the train // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); - const setLimits = minLocomotiveLimits(assignedLocomotives); + const setLimits = combinedLocomotiveLimits(assignedLocomotives); if (offYard) { warnings.push( `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, @@ -4799,6 +4775,51 @@ export class TrainSchedulingService { * type. This is the whole plannable pool for its schedules — the plan is * full when every consist wagon is allocated. */ + /** + * The physical wagons a schedule can actually plan against, by wagon type. + * + * A schedule built from a Train Builder train plans against ONLY that train's + * own consist. A legacy/dynamic-consist schedule plans against the boarding + * yards' loose pool: a slot's wagon may ride from the train's origin OR + * already sit at the booking's own boarding yard and attach there, so the + * usable fleet is the union across the origin and every boarding yard. + * + * Public because batch fill needs the SAME stock the allocator will later + * validate against — selecting a booking the allocator cannot place is how + * customers ended up paying for wagons that were never there. + */ + async wagonStockForSchedule( + scheduleId: string | undefined, + originYardId: string, + boardingYardIds: Array = [], + 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 +5368,7 @@ export class TrainSchedulingService { * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling * philosophy: nothing serviceable is filtered out — staff see the status, * whether the train sits at the origin yard yet, and its future schedules. - * Trains with fewer than two locomotives are omitted (never schedulable). + * Trains with no locomotive at all are omitted (never schedulable). */ async getAvailableTrainsForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); @@ -5385,7 +5406,7 @@ export class TrainSchedulingService { const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)])); return trains - .filter((train) => (train.locomotives ?? []).length >= 2) + .filter((train) => (train.locomotives ?? []).length >= 1) .map((train) => { const wagons = train.wagons ?? []; return { @@ -5417,7 +5438,15 @@ export class TrainSchedulingService { totalLengthMeters: roundTons( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), - maxPullWeightTons: roundTons(Number(train.capacityTons)), + // Live from the coupled set — `capacity_tons` still holds the old + // single-locomotive figure on trains built before pull weight summed. + maxPullWeightTons: roundTons( + combinedLocomotiveLimits( + (train.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + )?.maxPullWeightTons ?? Number(train.capacityTons), + ), atOriginYard: train.currentYardId === route.originYardId, futureScheduleCount: futureCounts.get(train.id) ?? 0, }; @@ -5467,7 +5496,7 @@ export class TrainSchedulingService { ); const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); @@ -5590,7 +5619,7 @@ export class TrainSchedulingService { .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) .map((slot) => slot.physicalWagonId as string), ); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), ); 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/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/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 2924169eb..eaf2f1c02 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -103,6 +103,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ // Each has its own permission so the two desks are genuinely separate people. perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), + // Freeze/unfreeze a signed contract. One key covers both directions — whoever + // may suspend must be able to lift it again. + perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'), ]; // Existing per-slug view ids are kept as-is: position-type grants reference @@ -447,6 +450,7 @@ export const FREIGHT_PERMS = { clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', + suspend: 'edr_freight_app:contracts:suspend', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -897,6 +901,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), + FREIGHT_PERMS.contracts.suspend, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; 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/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. + +