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/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.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a0b3b4e0e..0d7708789 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 @@ -1032,7 +1092,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 +1152,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 +1189,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 +1227,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/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 69773010c..3f0fc7abc 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 @@ -113,13 +113,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 +124,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; } @@ -352,11 +349,10 @@ 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) ); } @@ -720,7 +716,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 +729,7 @@ export class BookingClearanceService { ]); const filtered: Booking[] = []; for (const b of candidates) { - if (!this.isPhasedGeneralCustomsBooking(b)) continue; + if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if ( belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 99fed335a..1ca7cd84f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -60,6 +60,11 @@ export class BookingRequestService { 'This contract is completed — the full contracted quantity has been booked.', ); } + if (contract.status === 'SUSPENDED') { + throw new ConflictException( + 'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.', + ); + } if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', diff --git a/apps/edr-freight-api/src/modules/contracts/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.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 7e657cc07..3a97331af 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,20 @@ 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): GL initiates on the customer's behalf, the customer + * uploads the GL-input documents on the instance, GL approves them and runs + * the phased ET/DJ workflow (pre-booking milestones are seeded 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 +414,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 +432,27 @@ export class ContractBookingService { const isGlActor = actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + // Customs (Path B): GL initiates on the customer's behalf — assertGate + // rejects anyone else. Self-clearance (Path A): the customer initiates. const createdByRole = await this.assertGate(contract, isGlActor); 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 +497,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 +722,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 +830,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 +896,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 +976,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}: ${ @@ -1001,32 +992,27 @@ export class ContractBookingService { * allowed to create one for this contract's execution path. */ private async assertGate(contract: Contract, isGlActor: boolean): 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. + // Path B — Global Logistics initiates and completes the booking ON BEHALF + // OF the customer. The customer never books a customs contract himself; + // he only uploads documents on the instance GL opened for him. if (!isGlActor) { 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'; @@ -1041,6 +1027,40 @@ export class ContractBookingService { return isGlActor ? 'STAFF' : 'CUSTOMER'; } + /** + * GL worklist: executed ONE_TIME customs contracts with no live shipment + * instance yet. Customs contracts are initiated by GL on the customer's + * behalf, so without this list a signed contract would sit with nothing on any + * queue (clearance lives on the booking, and the booking does not exist yet). + * GENERAL customs is excluded — its instances are 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 +1438,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..5f85cf663 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, @@ -1262,43 +1275,15 @@ 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): GL initiates the booking, the customer 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 +1293,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..9aa37a0a7 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'; @@ -453,6 +456,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 +986,18 @@ 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); + @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(); } // ── 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 +1066,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). Customs contracts are initiated by GL Ethiopia.', }) initiateBooking( @Param('id', ParseUUIDPipe) id: string, @@ -1335,7 +1384,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..4ef525ff7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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..ddd0ee837 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', @@ -217,6 +219,14 @@ export class Contract extends BaseEntity { @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 +353,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/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 264e6df84..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); } 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/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/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index fcd04fb58..8c4833550 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, 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. + +