diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index ad171f488..c27d381e2 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -118,8 +118,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => { migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.DB_LOG - ? process.env.DB_LOG === "true" - : process.env.NODE_ENV === "development", + logging: + process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; }); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index caf161614..05061ed9a 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder { serviceType: this.valueOrDash( contract.serviceType?.serviceName ?? contract.serviceType?.code, ), - scheduledDate: this.formatDate(contract.estimatedShipmentDate), + // Estimated shipment date was removed from the contract wizard; the + // binding scheduled date is set per-booking, not on the contract. + scheduledDate: this.formatDate(null), contractType: this.valueOrDash(contract.contractType), cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts new file mode 100644 index 000000000..cc9437eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous + * or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item + * count for PER_ITEM). These two columns hold that amount on the booking; they + * stay 0 for container freight (which tracks it per line on booking_container) + * and for bulk cargo with no hazardous/reefer portion. The existing + * is_hazardous / is_reefer booleans remain the surcharge trigger. + */ +export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface { + name = 'AddBulkHazmatReeferQuantity1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts new file mode 100644 index 000000000..75288e94c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface { + name = 'PhasedClearanceCycleMeta1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts new file mode 100644 index 000000000..7165ea7a8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Admin-configurable minimum days between today and export RO vessel departure. */ +export class SeedRoVesselMinDays1829000000001 implements MigrationInterface { + name = 'SeedRoVesselMinDays1829000000001'; + private readonly code = 'ro_vessel_min_days'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '2', label: '2 days' }, + { value: '3', label: '3 days' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'RO vessel minimum lead time (days)', + 'Minimum days between today and the vessel departure date on an export Release Order.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts new file mode 100644 index 000000000..c108b7baa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingClearanceMeta1829000000002 implements MigrationInterface { + name = 'BookingClearanceMeta1829000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts new file mode 100644 index 000000000..5bdfe1f30 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface { + name = 'DropCargoTypeShowFreeTextBox1830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS show_free_text_box + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts new file mode 100644 index 000000000..701b52c18 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface { + name = 'RouteStatusAndSegmentKm1830000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE' + `); + + await queryRunner.query(` + UPDATE freight.routes + SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END + `); + + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_name" + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS name + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status) + `); + + await queryRunner.query(` + ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS name varchar(120) + `); + await queryRunner.query(` + UPDATE freight.routes SET name = id::text WHERE name IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true + `); + await queryRunner.query(` + UPDATE freight.routes + SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END + `); + + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS status + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_status" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts new file mode 100644 index 000000000..df57cd828 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface { + name = 'PreClearanceFinalizedAt1830000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index e15fcba0d..6e96dc6f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -58,7 +58,6 @@ export function buildCargoTypeTree( id: child.id, name: child.cargoTypeName, code: child.code, - show_free_text_box: child.showFreeTextBox, unit_of_measure: child.unitOfMeasure ?? null, }), ); 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 a9806f1f7..f9b160672 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 @@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => { {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, ruleEngineService }; } 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 ff08784a6..d62883d53 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,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => { fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository }; } @@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( fileUploadSettingsService as never, {} as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository }; } @@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields fileUploadSettingsService as never, {} as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); 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 cc2288963..66df02ac4 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 @@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => { {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); 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 cedc85f5f..898f78cd5 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,28 +7,30 @@ import { } from "@nestjs/common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { assertCanApproveBookingStep } from "../../common/freight-permission.util"; -import { BookingBatchService } from "../train-scheduling/booking-batch.service"; -import { eatDay } from "../train-scheduling/batch-window.util"; -import { isRoadService } from "./road.util"; -import { RuleEngineService } from "../rule-engine/rule-engine.service"; -import { FilesService } from "../files/files.service"; -import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; -import { BookingContractService } from "./booking-contract.service"; -import { BookingPricingService } from "./booking-pricing.service"; -import { BookingsRepository } from "./bookings.repository"; -import { assertBookingStatus } from "./booking-status.util"; -import { clearanceCodesForBooking } from "./clearance.util"; -import { - computeNextStep, - type BookingNextStep, -} from "./booking-next-step.util"; -import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; -import { PriceLineItemDto } from "./dto/generate-price-response.dto"; -import { Booking } from "./entities/booking.entity"; -import { BookingsService } from "./bookings.service"; -import { BookingInvoiceService } from "./booking-invoice.service"; +import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { isRoadService } from './road.util'; +import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { FilesService } from '../files/files.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingsRepository } from './bookings.repository'; +import { assertBookingStatus } from './booking-status.util'; +import { clearanceCodesForBooking } from './clearance.util'; +import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; +import { BookingsService } from './bookings.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; +import { ContractDocPhase } from '@edr/types'; + + import { Freight } from "@edr/types"; +import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() export class BookingTransitionService { @@ -44,8 +46,17 @@ export class BookingTransitionService { private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, - private readonly invoiceService: BookingInvoiceService, - ) { } + @Inject(forwardRef(() => BookingClearanceService)) + private readonly bookingClearanceService: BookingClearanceService, + @Inject(forwardRef(() => ClearanceWorkflowService)) + private readonly workflowService: ClearanceWorkflowService, + private readonly invoiceService: BookingInvoiceService, + + ) {} + + private isPhasedGeneralCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + } async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); @@ -511,8 +522,19 @@ export class BookingTransitionService { note: string | null; }>; allApproved: boolean; + phase?: string | null; + milestones?: unknown[]; + nextAction?: unknown; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + return this.bookingClearanceService.getClearanceView(bookingId); + } const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); @@ -668,6 +690,18 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "DOCUMENTS_UNDER_REVIEW", } as never); + + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onCustomerDocsUploadedForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } + return this.bookingsService.findById(bookingId); } @@ -735,6 +769,15 @@ export class BookingTransitionService { "A note is required when querying a document", ); } + if ( + status === 'QUERIED' && + this.isPhasedGeneralCustoms(booking) && + booking.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } await this.bookingsRepository.setDocumentReviewStatus( bookingId, @@ -751,8 +794,30 @@ export class BookingTransitionService { "CHANGES_REQUESTED", staffId, ); + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } } - return this.bookingsService.findById(bookingId); + + const updated = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(updated)) { + const allApproved = await this.isClearanceFullyApproved(updated); + if (allApproved) { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + const phase = + updated.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput; + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: phase, + } as never); + } + } + + return updated; } /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ @@ -788,7 +853,12 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); + if (this.isPhasedGeneralCustoms(booking)) { + throw new BadRequestException( + 'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', + ); + } + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 9548eee88..8c693af17 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -12,14 +12,15 @@ import { Request, Res, UnauthorizedException, + UploadedFile, UploadedFiles, UseInterceptors, -} from "@nestjs/common"; -import { CurrentUser } from "@edr/api-common"; -import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; -import { BookingStaff } from "../../common/booking-guards"; -import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -30,17 +31,22 @@ import { } from "@nestjs/swagger"; import type { Response } from "express"; -import { BookingContractService } from "./booking-contract.service"; -import { BookingPricingService } from "./booking-pricing.service"; -import { BookingTransitionService } from "./booking-transition.service"; -import { BookingReferenceDataService } from "./booking-reference-data.service"; -import { BookingsService } from "./bookings.service"; -import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; -import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from '../contracts/dto/phased-clearance.dto'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingsService } from './bookings.service'; +import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, ApproveStepDto, @@ -75,7 +81,8 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, - ) { } + private readonly bookingClearanceService: BookingClearanceService, + ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) @@ -358,7 +365,21 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get(":id/clearance") + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) + getBookingEtClearanceQueue() { + return this.bookingClearanceService.etQueue(); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) + getBookingDjClearanceQueue() { + return this.bookingClearanceService.djQueue(); + } + + @Get(':id/clearance') @ApiOperation({ summary: "Document-clearance grid (required docs + upload + GL review status)", @@ -469,7 +490,161 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(":id/staff/request-changes") + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + async uploadBookingDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeclaration( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + async adviseBookingDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: TCurrentUser, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + const booking = await this.bookingClearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.finalizePreClearance(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + async uploadBookingDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + const booking = await this.bookingClearanceService.uploadDutySlip(id, file); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + async uploadBookingTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadTransitPermit( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: TCurrentUser, + ) { + const result = await this.bookingClearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + return { + ...this.transitionService.enrichBookingResponse(result.booking), + hold: result.hold, + holdReason: result.holdReason, + }; + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + async requestBookingRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.requestRoAmendment( + id, + dto.note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + async confirmBookingExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.confirmExportRelease( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 5a7a0947f..2cc23b3fa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -4,38 +4,43 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; -import { CompaniesModule } from "../companies/companies.module"; -import { FilesModule } from "../files/files.module"; -import { MinioModule } from "../minio/minio.module"; -import { RuleEngineModule } from "../rule-engine/rule-engine.module"; -import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; -import { SignaturesModule } from "../signatures/signatures.module"; -import { BillingModule } from "../billing/billing.module"; -import { FirstMileModule } from "../first-mile/first-mile.module"; -import { BookingContractService } from "./booking-contract.service"; -import { BookingInvoiceService } from "./booking-invoice.service"; -import { BookingPricingService } from "./booking-pricing.service"; -import { BookingReferenceDataService } from "./booking-reference-data.service"; -import { BookingTransitionService } from "./booking-transition.service"; -import { BookingsController } from "./bookings.controller"; -import { BookingsRepository } from "./bookings.repository"; -import { ConsolidationService } from "./consolidation.service"; -import { BookingsService } from "./bookings.service"; -import { BookingApprovalStep } from "./entities/booking-approval-step.entity"; -import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity"; -import { BookingDocumentReview } from "./entities/booking-document-review.entity"; -import { BookingContainer } from "./entities/booking-container.entity"; -import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity"; -import { BookingContractSignature } from "./entities/booking-contract-signature.entity"; -import { BookingReviewNote } from "./entities/booking-review-note.entity"; -import { Booking } from "./entities/booking.entity"; +import { CompaniesModule } from '../companies/companies.module'; +import { FilesModule } from '../files/files.module'; +import { MinioModule } from '../minio/minio.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; +import { SignaturesModule } from '../signatures/signatures.module'; +import { BillingModule } from '../billing/billing.module'; +import { FirstMileModule } from '../first-mile/first-mile.module'; +import { BookingContractService } from './booking-contract.service'; +import { BookingInvoiceService } from './booking-invoice.service'; +// import { BookingPaymentController } from './booking-payment.controller'; +// import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingsController } from './bookings.controller'; +// import { PayController } from './pay.controller'; +import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { BookingsService } from './bookings.service'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingDocumentReview } from './entities/booking-document-review.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingContractSignature } from './entities/booking-contract-signature.entity'; +import { BookingReviewNote } from './entities/booking-review-note.entity'; +import { Booking } from './entities/booking.entity'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; -import { ContractPdfService } from "../../contracts/contract-pdf.service"; -import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; -import { ContractRendererService } from "../../contracts/contract-renderer.service"; -import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; -import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; -import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; + @Module({ imports: [ @@ -53,6 +58,8 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu BillingModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), + forwardRef(() => ContractsModule), + forwardRef(() => ContractsModule), FilesModule, MinioModule, CompaniesModule, 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 57e811e34..b99611c35 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + hazardousQuantity?: number; + reeferQuantity?: number; weightResult: ContainerWeightResult; }>, ): Promise { @@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository { const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); + // A per-line breakdown can never exceed the line's own quantity. + const clamp = (v?: number) => + Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0)); const row = containerRepo.create({ bookingId, containerTypeId: item.containerTypeId, quantity: item.quantity, + hazardousQuantity: clamp(item.hazardousQuantity), + reeferQuantity: clamp(item.reeferQuantity), vgmPerUnitTons: item.vgmPerUnitTons, totalVgmTons: totalVgm, wagonsRequired, @@ -483,6 +490,15 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** Bookings in any of the given statuses (clearance queue helpers). */ + async findByStatuses(statuses: string[]): Promise { + if (!statuses.length) return []; + return this.repository.find({ + where: { status: In(statuses) }, + order: { createdAt: 'DESC' }, + }); + } + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a4636cbf7..2d3901414 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -67,6 +67,17 @@ const NEEDS_ACTION_STATUSES = [ 'APPROVED_PENDING_SIGNATURE', ] as const; +/** + * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed + * the total cargo it's a portion of, and is never negative. + */ +function clampToCargo(value: number | undefined, cargoAmount: number): number { + const v = Number(value ?? 0); + if (!Number.isFinite(v) || v <= 0) return 0; + const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0; + return Math.min(v, cap); +} + @Injectable() export class BookingsService { constructor( @@ -506,6 +517,16 @@ export class BookingsService { // the container type at pricing time, so the booking-level flag stays off // for container freight to avoid double-counting. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container + // freight tracks this per line, so these are 0 for CONTAINER. + bulkHazardousQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm) + : 0, + bulkReeferQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm) + : 0, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, @@ -528,6 +549,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -665,6 +688,8 @@ export class BookingsService { containers, ); + const cargoAmount = + dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0); const updates: Record = { ...dto, freightType, @@ -675,6 +700,22 @@ export class BookingsService { freightType === 'BULK' ? (dto.isReefer ?? existing.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for + // container freight (per-line on the containers instead). + bulkHazardousQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0), + cargoAmount, + ) + : 0, + bulkReeferQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0), + cargoAmount, + ) + : 0, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -721,6 +762,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 35df9dcd3..c930d7aa1 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty({ example: 'BULK_COFFEE' }) code!: string; - @ApiProperty() - show_free_text_box!: boolean; - @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) unit_of_measure?: CargoUnitOfMeasure | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 9380faba5..c4d971f51 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -52,6 +52,28 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value)) vgmPerUnitTons!: number; + + @ApiPropertyOptional({ + description: 'How many of this line are hazardous (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ + description: 'How many of this line are refrigerated (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + reeferQuantity?: number; } /** @@ -320,6 +342,25 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + /** + * Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's + * unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed + * cargoTotalWeightVgm. Ignored for container freight (per-line on containers). + */ + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkReeferQuantity?: number; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 19aa3a199..3ae0fb64f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -321,6 +321,19 @@ export class Booking extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** + * Bulk-only hazardous / reefer amount, in the cargo's own unit of measure + * (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of + * `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container + * freight carries this per line on `booking_container` instead, so these stay + * 0 for CONTAINER bookings. The booleans above remain the surcharge trigger. + */ + @Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkHazardousQuantity!: number; + + @Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkReeferQuantity!: number; + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; @@ -435,6 +448,25 @@ export class Booking extends BaseEntity { @Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true }) glStationYardId?: string | null; + /** Per-booking phased clearance (GENERAL + customs). */ + @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) + clearanceCurrentPhase?: string | null; + + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; + /** GL staff user bound to this shipment by the station manager. */ @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) glAssignedStaffId?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts new file mode 100644 index 000000000..7ea818e34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -0,0 +1,188 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { BookingClearanceService } from './booking-clearance.service'; +import type { Booking } from '../bookings/entities/booking.entity'; + +const generalImportBooking = { + id: 'b-general', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + customsClearingEnabled: true, + contractKind: 'GENERAL', + contractId: 'c-1', + dutyRequired: true, + roHoldReason: null, + vesselDepartureDate: null, +} as Booking; + +const generalExportBooking = { + ...generalImportBooking, + id: 'b-export', + tradeDirection: 'EXPORT', + dutyRequired: null, +} as Booking; + +function makeService(overrides?: { + booking?: Booking; + workflowThrows?: boolean; +}) { + const booking = overrides?.booking ?? generalImportBooking; + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(booking), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const filesService = { + upsertByCode: jest.fn().mockResolvedValue({}), + findByResource: jest.fn().mockResolvedValue([]), + }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + }; + const workflowService = { + assertPriorCompleteForBooking: overrides?.workflowThrows + ? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete')) + : jest.fn().mockResolvedValue(undefined), + completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), + onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), + listMilestonesForBooking: jest.fn().mockResolvedValue([]), + resolvePhaseForBooking: jest.fn().mockReturnValue(null), + computeNextActionForBooking: jest.fn().mockReturnValue(null), + isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false), + markReadyForOperation: jest.fn().mockResolvedValue(undefined), + onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined), + }; + const milestoneService = { + adviseDuty: jest.fn().mockResolvedValue(undefined), + }; + const dropdownSettingsService = { + getByCode: jest.fn().mockResolvedValue({ + children: [{ value: '2' }], + }), + }; + + const service = new BookingClearanceService( + bookingsRepository as never, + bookingsService as never, + filesService as never, + fileUploadSettingsService as never, + workflowService as never, + milestoneService as never, + dropdownSettingsService as never, + ); + + return { + service, + bookingsRepository, + bookingsService, + filesService, + workflowService, + milestoneService, + }; +} + +describe('BookingClearanceService', () => { + describe('adviseDuty', () => { + it('skips duty milestones when duty is not required', async () => { + const { service, workflowService, bookingsRepository } = makeService(); + await service.adviseDuty('b-general', { dutyRequired: false }); + + expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + dutyRequired: false, + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + + it('records duty advice when duty applies', async () => { + const { service, milestoneService } = makeService(); + await service.adviseDuty('b-general', { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }); + + expect(milestoneService.adviseDuty).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }), + undefined, + ); + }); + }); + + describe('uploadDutySlip', () => { + it('rejects when duty is not required', async () => { + const { service } = makeService({ + booking: { ...generalImportBooking, dutyRequired: false } as Booking, + }); + await expect( + service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => { + const { service, filesService, workflowService, bookingsRepository } = makeService(); + const file = { fieldname: 'file' } as Express.Multer.File; + + await service.uploadDutySlip('b-general', file); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'b-general', + code: 'duty_tax_receipt', + file, + }), + ); + expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith( + 'b-general', + 'DUTY_TAX_PAID', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + }); + + describe('uploadDeclaration', () => { + it('rejects when a prior milestone is incomplete', async () => { + const { service } = makeService({ workflowThrows: true }); + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('uploadReleaseOrder', () => { + it('places RO on hold when vessel departs too soon', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().slice(0, 10); + + const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); + const result = await service.uploadReleaseOrder( + 'b-export', + { fieldname: 'ro' } as Express.Multer.File, + dateStr, + ); + + expect(result.hold).toBe(true); + expect(result.holdReason).toMatch(/minimum lead time/i); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-export', + expect.objectContaining({ roHoldReason: expect.any(String) }), + ); + }); + }); +}); 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 new file mode 100644 index 000000000..7bb835df2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -0,0 +1,618 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; + +export interface BookingClearanceView { + bookingId: string; + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: Array<{ + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + note: string | null; + }>; + allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + operationReady?: boolean; + preClearanceFinalized?: boolean; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; +} + +@Injectable() +export class BookingClearanceService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, + ) {} + + private async assertPhasedGeneralCustoms(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.'); + } + } + + private async loadBooking(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.assertPhasedGeneralCustoms(booking); + return booking; + } + + async getClearanceView(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); + + const documents: BookingClearanceView['documents'] = []; + + const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(booking); + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); + const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); + const boundary = await this.workflowService.isBoundaryCompleteForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + const workflowFiles = buildWorkflowFiles( + files, + booking.tradeDirection ?? 'IMPORT', + ); + + return { + bookingId, + status: booking.status, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: booking.dutyRequired ?? null, + roHold: Boolean(booking.roHoldReason), + roHoldReason: booking.roHoldReason ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt + ? booking.roAmendmentRequestedAt.toISOString() + : null, + operationReady: boundary, + preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + dutyAdvice, + workflowFiles, + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, + }; + } + + private async isClearanceFullyApproved(booking: Booking): Promise { + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) return true; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + isPhasedGeneralCustomsBooking(booking: Booking): boolean { + return ( + Boolean(booking.customsClearingEnabled) && + booking.contractKind === 'GENERAL' && + Boolean(booking.contractId) + ); + } + + async uploadDeclaration( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const allApproved = await this.isClearanceFullyApproved(booking); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: + tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async adviseDuty( + bookingId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DUTY_TAXES_ADVISED', + ); + + await this.bookingsRepository.update(bookingId, { + dutyRequired: dto.dutyRequired, + clearanceCurrentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + } as never); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkippedForBooking(bookingId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDuty( + bookingId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import bookings.'); + } + if (!booking.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async uploadTransitPermit( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'TRANSIT_PERMIT_UPLOADED', + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async finalizePreClearance(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); + } + + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (booking.preClearanceFinalizedAt) { + return this.bookingsService.findById(bookingId); + } + + await this.bookingsRepository.update(bookingId, { + preClearanceFinalizedAt: new Date(), + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async uploadDeliveryOrder( + bookingId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import bookings.'); + } + + if (!booking.preClearanceFinalizedAt) { + throw new BadRequestException( + 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', + ); + } + + await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'delivery_order', + file, + }); + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + + return this.bookingsService.findById(bookingId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + bookingId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'release_order', + file, + }); + + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + } as never); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.bookingsRepository.update(bookingId, { + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + return { + booking: await this.bookingsService.findById(bookingId), + hold: true, + holdReason: reason, + }; + } + + await this.bookingsRepository.update(bookingId, { + roHoldReason: null, + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'RELEASE_ORDER_SECURED', + userId, + ); + + return { booking: await this.bookingsService.findById(bookingId), hold: false }; + } + + async requestRoAmendment( + bookingId: string, + note?: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export bookings.'); + } + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.bookingsRepository.update(bookingId, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + if (userId) { + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'CHANGES_REQUESTED', + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async confirmExportRelease(bookingId: string, userId?: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'EXPORT_RELEASED', + ); + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + return this.bookingsService.findById(bookingId); + } + + async etQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); + } + return filtered; + } + + async djQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...DJ_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if ( + belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { + roHoldReason: b.roHoldReason, + preClearanceFinalizedAt: b.preClearanceFinalizedAt, + }) + ) { + filtered.push(b); + } + } + return filtered; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 9529cabbe..7a3695768 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository { async findById(id: string): Promise { return this.repository.findOne({ where: { id }, - relations: { contract: true }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 3389933a7..ce6f7bea4 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -22,6 +22,11 @@ const IMPORT_DEFS: Record> = { DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true }, + TRANSIT_PERMIT_UPLOADED: { + label: 'Transit Permit Uploaded', + ownerRegion: 'ET', + triggeredByDoc: true, + }, DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true }, WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true }, @@ -52,6 +57,11 @@ const EXPORT_DEFS: Record> = { FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true }, WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, + EXPORT_TRANSPORT_ISSUED: { + label: 'Export Transport Document Issued', + ownerRegion: 'ET', + triggeredByDoc: true, + }, CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false }, READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false }, LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 947fc6979..7d30b1c5b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { @@ -39,6 +39,15 @@ export class ClearanceMilestoneService { }); } + /** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */ + async seedPreBookingMilestonesOnBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + await this.seed(preBooking, { bookingId }); + } + /** Seed the post-booking milestones onto a freshly created booking. */ async seedPostBookingMilestones( bookingId: string, @@ -94,7 +103,7 @@ export class ClearanceMilestoneService { throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); } if (milestone.status === 'COMPLETED') { - throw new BadRequestException(`Milestone ${code} is already completed.`); + return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); @@ -178,7 +187,7 @@ export class ClearanceMilestoneService { throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); } if (milestone.status === 'COMPLETED') { - throw new BadRequestException(`Milestone ${code} is already completed.`); + return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); @@ -187,6 +196,99 @@ export class ClearanceMilestoneService { return this.repo.save(milestone); } + /** Skip optional milestones (e.g. duty when not required). */ + /** Reopen a completed contract milestone so review can continue after a query. */ + async reopenForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + /** Reopen a completed booking milestone so review can continue after a query. */ + async reopenForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + async skipForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async skipForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async completeWithMetadataForBooking( + bookingId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + return this.completeWithMetadata(bookingId, code, metadata, userId, note); + } + + /** Complete a contract milestone with structured metadata (duty advice, etc.). */ + async completeWithMetadataForContract( + contractId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') { + return milestone; + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + async adviseDutyForContract( + contractId: string, + input: { amount: number; currency: string; declarationSerial?: string }, + userId?: string, + ): Promise { + return this.completeWithMetadataForContract( + contractId, + 'DUTY_TAXES_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency, + declarationSerial: input.declarationSerial, + }, + userId, + ); + } + /** Complete a doc-triggered milestone when its document is uploaded/approved. */ async completeByDocTrigger( scope: { bookingId?: string; contractId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts new file mode 100644 index 000000000..f30b64597 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -0,0 +1,331 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import type { Contract } from './entities/contract.entity'; +import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; + +function ms( + code: string, + status: 'PENDING' | 'COMPLETED' | 'SKIPPED', + ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET', +): ClearanceMilestone { + return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone; +} + +function importThroughDeclaration(): ClearanceMilestone[] { + return [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'), + ms('DECLARED', 'PENDING', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; +} + +function makeService(milestones: ClearanceMilestone[]) { + const contractsRepository = { + currentCycle: jest.fn(), + update: jest.fn(), + setCycleStatus: jest.fn(), + updateCycle: jest.fn(), + }; + const milestoneService = { + listForContract: jest.fn().mockResolvedValue(milestones), + listForBooking: jest.fn().mockResolvedValue(milestones), + skipForContract: jest.fn(), + completeForContract: jest.fn(), + completeWithMetadataForContract: jest.fn(), + }; + const bookingsRepository = { update: jest.fn() }; + const service = new ClearanceWorkflowService( + contractsRepository as never, + milestoneService as never, + bookingsRepository as never, + ); + return { service, milestoneService, contractsRepository, bookingsRepository }; +} + +const importContract = { + id: 'c-import', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +const exportContract = { + id: 'c-export', + tradeDirection: 'EXPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +describe('ClearanceWorkflowService', () => { + describe('boundaryMilestone', () => { + it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => { + const { service } = makeService([]); + expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED'); + expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED'); + }); + }); + + describe('assertPriorComplete', () => { + it('rejects when a prior milestone is still pending', async () => { + const milestones = importThroughDeclaration().map((m) => + m.milestoneCode === 'DOCUMENTS_APPROVED' + ? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET') + : m, + ); + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows proceeding when prior milestones are completed or skipped', async () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'), + ).resolves.toBeUndefined(); + }); + }); + + describe('isBoundaryComplete', () => { + it('returns true only when boundary milestone is completed', async () => { + const done = [ + ...importThroughDeclaration().slice(0, -1), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service: doneSvc } = makeService(done); + await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true); + + const pending = importThroughDeclaration(); + const { service: pendingSvc } = makeService(pending); + await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false); + }); + }); + + describe('onDutySkipped', () => { + it('skips duty milestones on the contract', async () => { + const { service, milestoneService } = makeService([]); + await service.onDutySkipped('c-import'); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAXES_ADVISED', + ); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAX_PAID', + ); + }); + }); + + describe('computeNextAction — import happy path', () => { + it('prompts customer to upload docs first', () => { + const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]); + const next = service.computeNextAction(importContract, null, [ + ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'), + ]); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED'); + }); + + it('prompts ET review after customer docs', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, null, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/Review/i); + }); + + it('prompts duty toggle when declaration done and duty unset', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: null } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/duty/i); + }); + + it('prompts customer duty slip when duty required and advised', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('DUTY_TAX_PAID'); + }); + + it('skips duty path when duty not required', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); + }); + + it('prompts ET to finalize pre-clearance after transit permit', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/finalize pre-clearance/i); + }); + + it('prompts DJ for DO then ET booking when pre-booking complete', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const djNext = service.computeNextAction(importContract, cycle, milestones); + expect(djNext?.actor).toBe('GL_DJ'); + + const booked = milestones.map((m) => + m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m, + ); + const etNext = service.computeNextAction(importContract, cycle, booked); + expect(etNext?.actor).toBe('GL_ET'); + expect(etNext?.action).toMatch(/booking/i); + }); + }); + + describe('computeNextAction — export RO hold', () => { + it('surfaces DJ action when RO is on hold', () => { + const milestones = [ + ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'), + ]; + const cycle = { + roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).', + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(exportContract, cycle, milestones); + expect(next?.actor).toBe('GL_DJ'); + expect(next?.blockedReason).toMatch(/minimum lead time/i); + }); + }); + + describe('inferPhase', () => { + it('places import contract in customer duty phase when duty outstanding', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const phase = service.inferPhase(importContract, cycle, milestones); + expect(phase).toBe(ContractDocPhase.CustomerDuty); + }); + }); + + describe('queue helpers', () => { + it('returns first pending ET-owned milestone code', () => { + const { service } = makeService([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]); + expect(service.etPendingMilestoneCodes([])).toBeNull(); + expect( + service.etPendingMilestoneCodes([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]), + ).toBe('DOCUMENTS_APPROVED'); + }); + + it('returns first pending DJ-owned milestone code', () => { + const { service } = makeService([]); + expect( + service.djPendingMilestoneCodes([ + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]), + ).toBe('DO_COLLECTED'); + }); + }); + + describe('computeNextActionForBooking', () => { + it('prompts customer to proceed after import boundary on booking', () => { + const booking = { + tradeDirection: 'IMPORT', + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as Booking; + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextActionForBooking(booking, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.action).toMatch(/operation/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts new file mode 100644 index 000000000..758da27ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -0,0 +1,566 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ContractsRepository } from './contracts.repository'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { MilestoneMetadata } from './entities/clearance-milestone.entity'; +import { splitMilestones } from './clearance-milestone.catalog'; +import { Contract } from './entities/contract.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import type { ClearanceMetaState } from './clearance-workflow.types'; +import { metaFromBooking } from './clearance-workflow.types'; + +export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS'; + +export interface ClearanceNextAction { + actor: ClearanceActorRole; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; +} + +const IMPORT_BOUNDARY = 'DO_COLLECTED'; +const EXPORT_BOUNDARY = 'EXPORT_RELEASED'; + +const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED'; +const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED'; + +@Injectable() +export class ClearanceWorkflowService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly milestoneService: ClearanceMilestoneService, + private readonly bookingsRepository: BookingsRepository, + ) {} + + boundaryMilestone(tradeDirection: string): string { + return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY; + } + + // ── Contract scope (ONE_TIME) ───────────────────────────────────────────── + + async listMilestones(contractId: string): Promise { + return this.milestoneService.listForContract(contractId); + } + + async listMilestonesForBooking(bookingId: string): Promise { + return this.milestoneService.listForBooking(bookingId); + } + + async isBoundaryComplete(contractId: string, tradeDirection: string): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestones(contractId), + tradeDirection, + ); + } + + async isBoundaryCompleteForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + ); + } + + private isBoundaryCompleteForMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + ): boolean { + const code = this.boundaryMilestone(tradeDirection); + const m = milestones.find((x) => x.milestoneCode === code); + return m?.status === 'COMPLETED'; + } + + async assertBoundaryComplete(contract: Contract): Promise { + const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection); + if (!ok) { + throw new BadRequestException( + `Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`, + ); + } + } + + async assertPriorComplete( + contractId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestones(contractId), + tradeDirection, + targetCode, + ); + } + + async assertPriorCompleteForBooking( + bookingId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + targetCode, + ); + } + + private async assertPriorCompleteOnMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + targetCode: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const targetIdx = preBooking.findIndex((d) => d.code === targetCode); + if (targetIdx < 0) return; + + for (let i = 0; i < targetIdx; i++) { + + const code = preBooking[i]!.code; + const m = byCode.get(code); + if (!m) continue; + if (m.status === 'SKIPPED') continue; + if (m.status !== 'COMPLETED') { + throw new BadRequestException( + `Complete "${preBooking[i]!.label}" before proceeding.`, + ); + } + } + } + + async skipMilestones(contractId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForContract(contractId, code); + } + } + + async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForBooking(bookingId, code); + } + } + + async completeMilestone( + contractId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForContract( + contractId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForContract(contractId, code, userId); + } + + async completeMilestoneForBooking( + bookingId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForBooking( + bookingId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForBooking(bookingId, code, userId); + } + + async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestone(contractId, uploaded); + await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onCustomerDocsUploadedForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestoneForBooking(bookingId, uploaded); + await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onAllDocsApproved(contractId: string): Promise { + await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED'); + } + + async onAllDocsApprovedForBooking(bookingId: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + /** Customer doc queried or re-uploaded — document approval milestone must reopen. */ + async onDocumentReviewReopened(contractId: string): Promise { + await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED'); + } + + async onDocumentReviewReopenedForBooking(bookingId: string): Promise { + await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + async onDeclarationUploaded(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestone(contractId, 'DECLARED', userId); + } + + async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId); + } + + async onDutySkipped(contractId: string): Promise { + await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onDutySkippedForBooking(bookingId: string): Promise { + await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onExportReleased(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId); + await this.markReadyForBooking(contractId); + } + + async onExportReleasedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId); + await this.markReadyForOperation(bookingId); + } + + async markReadyForBooking(contractId: string): Promise { + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_READY_FOR_BOOKING', + clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { + clearanceReadyAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + } + + /** GENERAL per-booking: boundary complete → customer may proceed to operations. */ + async markReadyForOperation(bookingId: string): Promise { + await this.bookingsRepository.update(bookingId, { + status: 'CLEARANCE_READY', + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + } + + resolvePhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const meta: ClearanceMetaState = { + dutyRequired: cycle?.dutyRequired ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + currentPhase: cycle?.currentPhase ?? null, + }; + return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones); + } + + resolvePhaseForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.resolvePhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private resolvePhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + if (meta.currentPhase) { + return meta.currentPhase as ContractDocPhase; + } + return this.inferPhaseFromMeta(tradeDirection, meta, milestones); + } + + inferPhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.inferPhaseFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + }, + milestones, + ); + } + + inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase { + return this.inferPhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private inferPhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const isDone = (code: string) => + byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED'; + + const docUploaded = + tradeDirection === 'IMPORT' + ? isDone(IMPORT_DOC_UPLOADED) + : isDone(EXPORT_DOC_UPLOADED); + + if (!docUploaded) return ContractDocPhase.CustomerIntake; + if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return ContractDocPhase.GlDjCollection; + } + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; + return ContractDocPhase.GlEtPostClearance; + } + + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { + return ContractDocPhase.CustomerDuty; + } + if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance; + if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance; + if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection; + return ContractDocPhase.GlEtPostClearance; + } + + computeNextAction( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null, + }, + milestones, + 'contract', + ); + } + + computeNextActionForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + 'booking', + ); + } + + private computeNextActionFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + terminalScope: 'contract' | 'booking', + ): ClearanceNextAction | null { + if (meta.roHoldReason) { + return { + actor: 'GL_DJ', + action: 'Re-upload Release Order or request port amendment', + milestoneCode: 'RELEASE_ORDER_SECURED', + blockedReason: meta.roHoldReason, + }; + } + + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const pending = (code: string) => { + const m = byCode.get(code); + return m && m.status === 'PENDING'; + }; + const isDone = (code: string) => { + const m = byCode.get(code); + return m?.status === 'COMPLETED' || m?.status === 'SKIPPED'; + }; + + const docCode = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + + if (pending(docCode) || !isDone(docCode)) { + return { + actor: 'CUSTOMER', + action: 'Upload clearance documents', + milestoneCode: docCode, + }; + } + + if (!isDone('DOCUMENTS_APPROVED')) { + return { + actor: 'GL_ET', + action: 'Review and approve customer documents', + milestoneCode: 'DOCUMENTS_APPROVED', + }; + } + + const terminalAction = + terminalScope === 'contract' + ? 'Create shipment booking' + : 'Proceed to request operation'; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + if (!isDone(EXPORT_BOUNDARY)) { + return { + actor: 'GL_ET', + action: 'Confirm export release', + milestoneCode: EXPORT_BOUNDARY, + }; + } + if (terminalScope === 'booking') { + if (!isDone('FREIGHT_PAYMENT_SETTLED')) { + return { + actor: 'CUSTOMER', + action: 'Pay freight charges', + milestoneCode: 'FREIGHT_PAYMENT_SETTLED', + }; + } + if (!isDone('WAGON_ALLOCATED')) { + return { + actor: 'OPERATIONS', + action: 'Allocate wagon', + milestoneCode: 'WAGON_ALLOCATED', + }; + } + if (!isDone('EXPORT_TRANSPORT_ISSUED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit', + milestoneCode: 'EXPORT_TRANSPORT_ISSUED', + }; + } + return null; + } + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: EXPORT_BOUNDARY, + }; + } + + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + + if (meta.dutyRequired === null || meta.dutyRequired === undefined) { + return { + actor: 'GL_ET', + action: 'Set whether duty/tax applies', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + + if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) { + if (!isDone('DUTY_TAXES_ADVISED')) { + return { + actor: 'GL_ET', + action: 'Advise duty and tax amount', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + return { + actor: 'CUSTOMER', + action: 'Upload duty/tax payment slip', + milestoneCode: 'DUTY_TAX_PAID', + }; + } + + if (!isDone('TRANSIT_PERMIT_UPLOADED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit screenshot', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!meta.preClearanceFinalizedAt) { + return { + actor: 'GL_ET', + action: 'Finalize pre-clearance', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!isDone(IMPORT_BOUNDARY)) { + return { + actor: 'GL_DJ', + action: 'Upload Delivery Order', + milestoneCode: IMPORT_BOUNDARY, + }; + } + + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: IMPORT_BOUNDARY, + }; + } + + etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET'); + return pending?.milestoneCode ?? null; + } + + djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ'); + return pending?.milestoneCode ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts new file mode 100644 index 000000000..25d01b17e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts @@ -0,0 +1,33 @@ +import type { ContractDocPhase } from '@edr/types'; + +/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */ +export interface ClearanceMetaState { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + currentPhase?: ContractDocPhase | string | null; + preClearanceFinalizedAt?: Date | null; +} + +export type ClearanceScope = + | { kind: 'contract'; contractId: string } + | { kind: 'booking'; bookingId: string }; + +export function metaFromBooking(booking: { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + clearanceCurrentPhase?: string | null; + preClearanceFinalizedAt?: Date | null; +}): ClearanceMetaState { + return { + dutyRequired: booking.dutyRequired ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null, + roHoldReason: booking.roHoldReason ?? null, + currentPhase: booking.clearanceCurrentPhase ?? null, + preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null, + }; +} 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 b077930b7..b0a5cc636 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 @@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ @@ -56,6 +57,7 @@ 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 dataSource: DataSource, ) {} @@ -194,9 +196,11 @@ export class ContractBookingService { clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { - // Per-booking clearance: seed post-booking milestones on the booking (no - // cycle needed) and leave the contract active. The booking now drives its - // own clearance via the booking-level pipeline. + // Per-booking clearance: seed full milestone timeline on the booking. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); await this.milestoneService.seedPostBookingMilestones( booking.id, contract.tradeDirection, @@ -246,10 +250,14 @@ export class ContractBookingService { } return 'GL_ET'; } - // ONE_TIME customs — UNCHANGED: requires the finalized contract cycle. - if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { + // ONE_TIME customs — pre-booking boundary milestone must be complete. + const boundaryOk = await this.workflowService.isBoundaryComplete( + contract.id, + contract.tradeDirection, + ); + if (!boundaryOk) { throw new BadRequestException( - 'Contract clearance is not ready for booking yet.', + 'Pre-booking clearance is not complete — booking cannot be created yet.', ); } return 'GL_ET'; 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 71cfa0580..a09de407c 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 @@ -1,13 +1,23 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; +import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; 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'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface ContractClearanceDocument { fileKey: string; @@ -34,6 +44,39 @@ export interface ContractClearanceView { outputCode: string | null; documents: ContractClearanceDocument[]; allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + bookingReady?: boolean; + preClearanceFinalized?: boolean; + /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ + exportClearanceFinalized?: boolean; + linkedBookingId?: string | null; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; } @Injectable() @@ -41,13 +84,57 @@ export class ContractClearanceService { constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, + private readonly bookingsService: BookingsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, ) {} + private isPhasedCustoms(contract: Contract): boolean { + return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME'; + } + + private assertPhasedCustoms(contract: Contract): void { + if (!this.isPhasedCustoms(contract)) { + throw new BadRequestException( + 'Phased clearance (Phase 1) applies to one-time customs contracts.', + ); + } + } + + /** + * Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing + * phased milestones. Revert that state so declaration / DO steps can proceed. + */ + private async reconcilePrematureBookingReady( + contractId: string, + contract: Contract, + bookingReady: boolean, + ): Promise { + if ( + !this.isPhasedCustoms(contract) || + contract.status !== 'CLEARANCE_READY_FOR_BOOKING' || + bookingReady + ) { + return contract; + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + return this.contractsService.findById(contractId); + } + /** The pre-booking clearance document grid for a contract (Path B). */ async getClearanceView(contractId: string): Promise { - const contract = await this.contractsService.findById(contractId); + let contract = await this.contractsService.findById(contractId); const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); @@ -109,6 +196,47 @@ export class ContractClearanceService { } const allApproved = await this.isClearanceFullyApproved(contract); + const milestones = await this.workflowService.listMilestones(contractId); + let boundary = await this.workflowService.isBoundaryComplete( + contractId, + contract.tradeDirection, + ); + contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); + const phase = this.workflowService.resolvePhase(contract, cycle, milestones); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + let workflowFiles = buildWorkflowFiles( + files, + contract.tradeDirection ?? 'IMPORT', + ); + if (cycle?.bookingId) { + const bookingFiles = await this.filesService.findByResource( + cycle.bookingId, + 'bookings', + ); + const bookingWorkflow = buildWorkflowFiles( + bookingFiles, + contract.tradeDirection ?? 'IMPORT', + ); + const byCode = new Map(workflowFiles.map((f) => [f.code, f])); + for (const row of bookingWorkflow) { + if (row.file) byCode.set(row.code, row); + } + workflowFiles = [...byCode.values()]; + } + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const booking = await this.bookingsService.findById(cycle.bookingId); + if (booking) { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } + } return { contractId, @@ -120,6 +248,55 @@ export class ContractClearanceService { outputCode, documents, allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: cycle?.dutyRequired ?? null, + roHold: Boolean(cycle?.roHoldReason), + roHoldReason: cycle?.roHoldReason ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt + ? cycle.roAmendmentRequestedAt.toISOString() + : null, + bookingReady: boundary, + preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), + exportClearanceFinalized: Boolean(cycle?.completedAt), + linkedBookingId: cycle?.bookingId ?? null, + dutyAdvice, + workflowFiles, + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): ContractClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, }; } @@ -154,6 +331,59 @@ export class ContractClearanceService { ); } + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. @@ -209,8 +439,16 @@ export class ContractClearanceService { clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); if (cycle) { - await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', { + currentPhase: ContractDocPhase.GlEtReview, + }); } + + if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { + await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); + await this.workflowService.onDocumentReviewReopened(contractId); + } + return this.contractsService.findById(contractId); } @@ -294,25 +532,22 @@ export class ContractClearanceService { note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); - // Reviewing is allowed both while the batch is UNDER_REVIEW and after it has - // dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips - // the contract to "awaiting" (the customer must re-upload), but the reviewer - // may still be working through the rest of the batch. Restricting to - // UNDER_REVIEW only would 409 every review after the first query. - if ( - contract.status !== 'CLEARANCE_UNDER_REVIEW' && - contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' - ) { - throw new ConflictException( - `Cannot review clearance documents on status "${contract.status}".`, - ); - } + this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } const { inputCode, outputCode } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); + if ( + status === 'QUERIED' && + this.isPhasedCustoms(contract) && + cycle?.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, @@ -348,6 +583,33 @@ export class ContractClearanceService { if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onDocumentReviewReopened(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtReview, + }); + } + } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); + const refreshed = await this.contractsService.findById(contractId); + if ( + refreshed.customsClearingEnabled && + refreshed.contractKind === 'ONE_TIME' && + (await this.isClearanceFullyApproved(refreshed)) + ) { + await this.workflowService.onAllDocsApproved(contractId); + const c = await this.contractsRepository.currentCycle(contractId); + if (c) { + await this.contractsRepository.updateCycle(c.id, { + currentPhase: + refreshed.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + } } return this.contractsService.findById(contractId); @@ -359,11 +621,7 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot upload output documents on status "${contract.status}".`, - ); - } + this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract has no customs output documents'); @@ -384,8 +642,10 @@ export class ContractClearanceService { /** * GL ET finalizes Path B pre-booking clearance: requires every customer - * document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING - * (GL then creates the booking). Rejects self-clearance (Path A) contracts. + * document APPROVED. For phased customs (ONE_TIME), document review completes + * here — booking readiness is set only after delivery order (import) or export + * release via the milestone workflow. Non-phased customs still jump straight to + * CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts. */ async finalize(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -394,11 +654,7 @@ export class ContractClearanceService { 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -407,6 +663,24 @@ export class ContractClearanceService { ); } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onAllDocsApproved(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + return this.contractsService.findById(contractId); + } + const { outputCode } = contractClearanceCodes(contract); if (outputCode) { const setting = await this.fileUploadSettingsService.getByCode(outputCode); @@ -452,11 +726,7 @@ export class ContractClearanceService { 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -479,19 +749,14 @@ export class ContractClearanceService { } /** - * GL ET clearance hub: every customs (Path B) contract that still needs - * customs clearance — awaiting the customer's documents, under GL review, or - * finalized and waiting for the customer to create the booking in the portal. + * GL ET clearance hub: every customs (Path B) contract in phased clearance, + * including after booking is created. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, - statuses: [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - ], + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -536,4 +801,492 @@ export class ContractClearanceService { sortOrder: filter.sortOrder ?? 'DESC', }); } + + // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── + + /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + private async ensureDeclarationPrerequisites( + contractId: string, + contract: Contract, + ): Promise { + const allApproved = await this.isClearanceFullyApproved(contract); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestones(contractId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApproved(contractId); + } + } + + async uploadDeclaration( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + await this.ensureDeclarationPrerequisites(contractId, contract); + await this.workflowService.assertPriorComplete( + contractId, + contract.tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.onDeclarationUploaded(contractId, userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + }); + } + + return this.contractsService.findById(contractId); + } + + async adviseDuty( + contractId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED'); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + dutyRequired: dto.dutyRequired, + currentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + }); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkipped(contractId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDutyForContract( + contractId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.contractsService.findById(contractId); + } + + async uploadDutySlip( + contractId: string, + file: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID'); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async uploadTransitPermit( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async finalizePreClearance(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import contracts.'); + } + + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (cycle.preClearanceFinalizedAt) { + return this.contractsService.findById(contractId); + } + + await this.contractsRepository.updateCycle(cycle.id, { + preClearanceFinalizedAt: new Date(), + currentPhase: ContractDocPhase.GlDjCollection, + }); + + return this.contractsService.findById(contractId); + } + + async uploadDeliveryOrder( + contractId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.preClearanceFinalizedAt) { + throw new BadRequestException( + 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', + ); + } + + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'delivery_order', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + + return this.contractsService.findById(contractId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + contractId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'release_order', + file, + }); + + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + }); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason }; + } + + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: null, + currentPhase: ContractDocPhase.GlEtOutput, + }); + await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + + return { contract: await this.contractsService.findById(contractId), hold: false }; + } + + async requestRoAmendment( + contractId: string, + note?: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.contractsRepository.updateCycle(cycle.id, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + + if (userId) { + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CHANGES_REQUESTED', + userId, + 'GL_DJ', + ); + } + + return this.contractsService.findById(contractId); + } + + async confirmExportRelease(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED'); + + await this.workflowService.onExportReleased(contractId, userId); + return this.contractsService.findById(contractId); + } + + /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */ + async finalizeExportClearance(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export clearance finalize applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.bookingId) { + throw new BadRequestException( + 'A shipment booking must exist before export clearance can be finalized.', + ); + } + if (cycle.completedAt) { + return this.contractsService.findById(contractId); + } + + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const transportDone = bookingMilestones.some( + (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED', + ); + if (!transportDone) { + throw new BadRequestException( + 'Upload the transit permit before finalizing export clearance.', + ); + } + + await this.contractsRepository.updateCycle(cycle.id, { + completedAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + + void userId; + 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-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4fcbc9a6c..e87112bc2 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 @@ -172,15 +172,11 @@ export class ContractTransitionService { const cargoTypeId = (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; - // US-06 routing: bulk always needs director approval; container needs it only - // when its cargo type flags it. Resolve the chain via the same approval_rules - // source of truth the booking flow uses (no booking row is created here). - let requiresDirectorApproval = contract.freightType === 'BULK'; + // Resolve the chain from the cargo type flag only. + let requiresDirectorApproval = false; if (cargoTypeId) { const cargoType = await this.cargoTypesService.findById(cargoTypeId); - if (cargoType?.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false; } const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); @@ -345,6 +341,14 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML 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 0ef013347..5e712a177 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -9,13 +9,16 @@ import { Patch, Post, Query, + Res, UnauthorizedException, UploadedFiles, + UploadedFile, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, @@ -40,11 +43,13 @@ import { ContractsService } from './contracts.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { BookingsService } from '../bookings/bookings.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -70,6 +75,10 @@ import { CompleteMilestoneDto, ReportIncidentDto, } from './dto/gl-operations.dto'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from './dto/phased-clearance.dto'; @ApiTags('contracts') @Controller('contracts') @@ -85,6 +94,8 @@ export class ContractsController { private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, + private readonly bookingClearanceService: BookingClearanceService, + private readonly bookingsService: BookingsService, ) {} // ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── @@ -417,6 +428,26 @@ export class ContractsController { }; } + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @@ -459,7 +490,10 @@ export class ContractsController { } @Post(':id/clearance/review') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) reviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @@ -489,11 +523,164 @@ export class ContractsController { @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) - @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' }) + @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' }) finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalize(id); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' }) + uploadDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' }) + adviseContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + return this.clearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' }) + finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.finalizePreClearance(id); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) + uploadContractDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.clearanceService.uploadDutySlip(id, file); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' }) + uploadTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + uploadDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + uploadReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' }) + requestRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user)); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET confirms export release after declaration' }) + confirmExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); + } + + @Post(':id/clearance/finalize-export-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL ET finalizes export clearance after post-booking transit permit upload', + }) + finalizeExportClearance( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); + } + + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) + etClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.etQueue(filter); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) + djClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.djQueue(filter); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -674,6 +861,18 @@ export class ContractsController { }); } + @Post('bookings/:bookingId/transport-document') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) + uploadTransportDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @@ -692,11 +891,16 @@ export class ContractsController { @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) - uploadDutySlip( + async uploadDutySlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { - return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]); + const file = (files ?? [])[0]; + const booking = await this.bookingsService.findById(bookingId); + if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + return this.bookingClearanceService.uploadDutySlip(bookingId, file); + } + return this.glOperationsService.uploadDutySlip(bookingId, file); } @Get('bookings/:bookingId/incidents') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index c595dbf5f..94f469112 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; @@ -18,6 +18,8 @@ import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -71,7 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). - BookingsModule, + forwardRef(() => BookingsModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -85,6 +87,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, GlOperationsService, @@ -103,6 +107,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, ], 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 354f02e8c..9e464db51 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository { cycleId: string, status: string, fields: Partial< - Pick + Pick< + ContractClearanceCycle, + | 'bookingId' + | 'clearanceReadyAt' + | 'completedAt' + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + > > = {}, ): Promise { await this.dataSource @@ -526,6 +536,25 @@ export class ContractsRepository extends BaseRepository { .update(cycleId, { status, ...fields } as never); } + async updateCycle( + cycleId: string, + fields: Partial< + Pick< + ContractClearanceCycle, + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + | 'status' + | 'preClearanceFinalizedAt' + | 'completedAt' + > + >, + ): Promise { + await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never); + } + /** Link the GL-created booking to a clearance cycle. */ async linkBooking(cycleId: string, bookingId: string): Promise { await this.dataSource 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 70d2632fa..8d864aac7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -200,9 +200,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, - estimatedShipmentDate: dto.estimatedShipmentDate - ? new Date(dto.estimatedShipmentDate) - : null, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', @@ -347,9 +344,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; - if (dto.estimatedShipmentDate) { - updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); - } if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 70dba1e90..b20b99575 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -4,7 +4,6 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsDateString, IsIn, IsNumber, IsOptional, @@ -219,14 +218,6 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; - @ApiPropertyOptional({ - description: 'Non-binding estimate from the wizard (NOT validated against departures)', - example: '2026-07-15T00:00:00.000Z', - }) - @IsOptional() - @IsDateString() - estimatedShipmentDate?: string; - @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts new file mode 100644 index 000000000..f48653822 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class AdviseContractDutyDto { + @ApiProperty({ description: 'Whether the customer must pay duty/tax' }) + @IsBoolean() + dutyRequired!: boolean; + + @ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Declaration / payment reference code' }) + @IsOptional() + @IsString() + declarationSerial?: string; +} + +export class ReleaseOrderDto { + @ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' }) + @IsString() + vesselDepartureDate!: string; +} + +export class RoAmendmentDto { + @ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index e29985f2b..3c101f58e 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -34,4 +34,25 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) completedAt?: Date | null; + + /** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */ + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + /** Export RO vessel departure date (Path B export). */ + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true }) + currentPhase?: string | null; + + /** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */ + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index e8f53f4c6..73ca3a65d 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -8,6 +8,7 @@ import { IncidentType, } from './entities/clearance-incident.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { persistExportTransportUploads } from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -21,6 +22,7 @@ const DOC_CODE_TO_MILESTONE: Record = { import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET + export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation }; /** @@ -158,4 +160,44 @@ export class GlOperationsService { } return { uploaded: files.length, completedMilestones }; } + + /** + * GL ET uploads export transport document after wagon allocation (export ONE_TIME). + */ + async uploadTransportDocument( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Transport document upload applies to export shipments only.'); + } + + const milestones = await this.milestoneService.listForBooking(bookingId); + const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonDone = + wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED'; + if (!wagonDone) { + throw new BadRequestException( + 'Wagon must be allocated before the transport document can be uploaded.', + ); + } + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistExportTransportUploads(this.filesService, bookingId, files); + + if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } + + await this.milestoneService.completeByDocTrigger( + { bookingId }, + 'EXPORT_TRANSPORT_ISSUED', + ); + + return { uploaded: true, milestoneCompleted: true }; + } } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts new file mode 100644 index 000000000..cf044d6a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -0,0 +1,125 @@ +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util'; + +describe('buildWorkflowFiles', () => { + const resourceFiles = [ + { code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' }, + { code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' }, + { + code: 'transit_permitted', + id: 'f-transit', + name: 'transit.png', + url: '/files/transit', + }, + { + code: 'duty_tax_notice', + id: 'f-duty', + name: 'notice.pdf', + url: '/files/duty', + }, + { code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' }, + ]; + + it('includes declaration and transit files even when they also appear in GL output document settings', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']), + ); + }); + + it('includes multi-file declaration uploads alongside catalog codes', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'declaration_0', + id: 'f-dec-0', + name: 'decl-a.pdf', + url: '/files/decl-a', + }, + { + code: 'declaration_1', + id: 'f-dec-1', + name: 'decl-b.pdf', + url: '/files/decl-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']), + ); + expect(result.find((f) => f.code === 'declaration_0')?.label).toBe( + 'Declaration document 1', + ); + }); + + it('includes multi-file import transit permit uploads', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'transit_permit_0', + id: 'f-tp-0', + name: 'permit-a.pdf', + url: '/files/tp-a', + }, + { + code: 'transit_permit_1', + id: 'f-tp-1', + name: 'permit-b.pdf', + url: '/files/tp-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']), + ); + expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1'); + }); + + it('does not include non-catalog customer document codes', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false); + }); +}); + +describe('belongsOnDjClearanceQueue', () => { + it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => { + expect( + belongsOnDjClearanceQueue( + 'IMPORT', + { preClearanceFinalizedAt: new Date('2026-01-01') }, + [], + ), + ).toBe(true); + }); + + it('keeps contracts with completed Djibouti milestones', () => { + expect( + belongsOnDjClearanceQueue('IMPORT', null, [ + { ownerRegion: 'DJ', status: 'COMPLETED' }, + ]), + ).toBe(true); + }); + + it('excludes import contracts still on Ethiopia-side clearance only', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + }); +}); + +describe('belongsOnEtClearanceQueue', () => { + it('keeps contracts once phased clearance milestones exist', () => { + expect( + belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]), + ).toBe(true); + }); + + it('excludes contracts with no clearance milestones', () => { + expect(belongsOnEtClearanceQueue([])).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts new file mode 100644 index 000000000..89fbf797e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -0,0 +1,319 @@ +import { BadRequestException } from '@nestjs/common'; +import { + catalogEntriesForTradeDirection, + declarationFileLabel, + isDeclarationFileCode, + isImportTransitPermitFileCode, + isExportTransportFileCode, + exportTransportFileLabel, + transitPermitFileLabel, + type ClearanceWorkflowFile, +} from '@edr/types'; + +/** Require at least one declaration file in the upload batch. */ +export function assertDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } +} + +/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `declaration_${index}`, + })); +} + +type DeclarationFileStore = { + findByResource( + resourceId: string, + resource: string, + ): Promise>; + deleteByCode(resourceId: string, resource: string, code: string): Promise; + upload(input: { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; + }): Promise; +}; + +/** Replace all declaration files on a resource with a new multi-file upload batch. */ +export async function persistDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeclarationFieldNames(files); + assertDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `declaration_${index}`, + file, + }), + ), + ); +} + +/** Require at least one transit permit file in the upload batch. */ +export function assertTransitPermitFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */ +export function normalizeTransitPermitFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `transit_permit_${index}`, + })); +} + +/** Replace all import transit permit files on a resource with a new multi-file batch. */ +export async function persistTransitPermitUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeTransitPermitFieldNames(files); + assertTransitPermitFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isImportTransitPermitFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `transit_permit_${index}`, + file, + }), + ), + ); +} + +/** Require at least one export transport document in the upload batch. */ +export function assertExportTransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +export function normalizeExportTransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `export_transport_document_${index}`, + })); +} + +/** Replace all export transport documents on a booking with a new multi-file batch. */ +export async function persistExportTransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeExportTransportFieldNames(files); + assertExportTransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isExportTransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `export_transport_document_${index}`, + file, + }), + ), + ); +} + +export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { + if (typeof value === 'boolean') return value; + if (value === undefined || value === '') return false; + return value === 'true' || value === '1'; +} + +type DjQueueMilestone = { + ownerRegion?: string | null; + status: string; +}; + +type DjQueueCycle = { + preClearanceFinalizedAt?: Date | null; + roHoldReason?: string | null; +} | null | undefined; + +/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */ +export function belongsOnDjClearanceQueue( + tradeDirection: string | null | undefined, + cycle: DjQueueCycle, + milestones: DjQueueMilestone[], + extras?: { + roHoldReason?: string | null; + preClearanceFinalizedAt?: Date | null; + }, +): boolean { + const roHold = cycle?.roHoldReason ?? extras?.roHoldReason; + if (roHold) return true; + + const hasDjActivity = milestones.some( + (m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'), + ); + if (hasDjActivity) return true; + + const preFinalized = + cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; + if (tradeDirection === 'IMPORT' && preFinalized) return true; + + return false; +} + +/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ +export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean { + return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED'); +} + +/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */ +export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES; + +/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'FULLY_EXECUTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'ROAD_DISPATCH_PENDING', + 'IN_TRANSIT', + 'PAID', + 'COMPLETED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ +export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; + +/** Build labeled phased-customs file rows from resource files. */ +export function buildWorkflowFiles( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + tradeDirection: string, +): ClearanceWorkflowFile[] { + const fileByCode = new Map( + files.filter((f) => f.code).map((f) => [f.code as string, f]), + ); + const out: ClearanceWorkflowFile[] = []; + const included = new Set(); + + for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { + const file = fileByCode.get(entry.code) ?? null; + if (!file) continue; + included.add(entry.code); + out.push({ + code: entry.code, + label: entry.label, + uploadedBy: entry.uploadedBy, + category: entry.category, + file: { id: file.id, name: file.name, url: file.url }, + }); + } + + const extraDeclarations = files + .filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: declarationFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + if (tradeDirection === 'IMPORT') { + const extraTransit = files + .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraTransit.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: transitPermitFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + if (tradeDirection === 'EXPORT') { + const extraExportTransport = files + .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraExportTransport.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: exportTransportFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + return out; +} diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 4bf8362f9..ec1fb6fa9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -61,6 +61,14 @@ export class FilesService { return this.upload(input); } + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.filesRepository.deleteByCode(resourceId, resource, code); + } + async uploadMany( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0db38a751..57c95eab3 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -4,22 +4,22 @@ import { HttpCode, HttpStatus, Post, - UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { Public } from "@edr/api-common"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; import { PaymentService } from "./payment.service"; /** * Consumer side of the payment microservice's outbox relay. - * Only the payment service may call this (shared SERVICE_AUTH_TOKEN). + * WARNING: currently unauthenticated — anyone who can reach the API can mark + * payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network. * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") -@UseGuards(ServiceAuthGuard) +@Public() @Controller("internal/payments") export class InternalPaymentController { constructor(private readonly paymentService: PaymentService) { } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 330521218..532e1c06b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,8 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +import { FirstMileModule } from "../first-mile/first-mile.module"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] { HttpModule.register({ timeout: 10_000 }), ConfigModule, forwardRef(() => BillingModule), + forwardRef(() => TrainSchedulingModule), + FirstMileModule, TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 45e737607..3f4d2e4ff 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -1,19 +1,31 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +import { RouteStatus } from '../entities/route.entity'; export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; + + @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) + @IsOptional() + @IsNumber() + @Min(0) + distanceKm?: number; } export class CreateRouteDto { - @ApiProperty() - @IsString() - @MaxLength(120) - name!: string; - @ApiProperty({ type: [CreateRouteMilestoneDto] }) @IsArray() @ArrayMinSize(2) @@ -21,8 +33,8 @@ export class CreateRouteDto { @Type(() => CreateRouteMilestoneDto) milestones!: CreateRouteMilestoneDto[]; - @ApiPropertyOptional() + @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() - @IsBoolean() - isActive?: boolean; + @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) + status?: RouteStatus; } diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts index 020a34cdf..59188a2a6 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -1,16 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +import { RouteStatus } from '../entities/route.entity'; export class FilterRoutesDto { - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' }) @IsOptional() @IsString() search?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) - @IsBoolean() - isActive?: boolean; + @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) + status?: RouteStatus; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts index 63e37b8ec..c40baaa82 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity { @Column({ name: 'sequence_no', type: 'int' }) sequenceNo!: number; + + /** Kilometres from the previous stop (0 for origin). */ + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 8c6e4785e..9c25778dc 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -4,13 +4,11 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Yard } from '../../rule-engine/entities/yard.entity'; import { RouteMilestone } from './route-milestone.entity'; -@Entity({ schema: 'freight', name: 'routes' }) -@Index(['name']) -@Index(['isActive']) -export class Route extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 120, unique: true }) - name!: string; +export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; +@Entity({ schema: 'freight', name: 'routes' }) +@Index(['status']) +export class Route extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; @@ -25,9 +23,24 @@ export class Route extends BaseEntity { @JoinColumn({ name: 'destination_yard_id' }) destinationYard?: Yard; - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive!: boolean; + @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) + status!: RouteStatus; @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) milestones?: RouteMilestone[]; } + +export function formatRouteLabel(route: { + originYard?: { code?: string; name?: string } | null; + destinationYard?: { code?: string; name?: string } | null; +}): string { + const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin'; + const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination'; + return `${origin} → ${dest}`; +} + +export function totalRouteDistanceKm( + milestones: Array<{ distanceKm?: number | string | null }>, +): number { + return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0); +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4c8e62498..937ffb0ae 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -1,12 +1,12 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, ILike } from 'typeorm'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { Route } from './entities/route.entity'; +import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; @Injectable() @@ -16,11 +16,10 @@ export class RoutesService { private readonly routesRepository: RoutesRepository, ) {} - findAll(filter: FilterRoutesDto): Promise { - return this.routesRepository.findAll({ + async findAll(filter: FilterRoutesDto): Promise { + const routes = await this.routesRepository.findAll({ where: { - ...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), - ...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}), + ...(filter.status ? { status: filter.status } : {}), }, relations: { originYard: true, @@ -28,10 +27,33 @@ export class RoutesService { milestones: { yard: true }, }, order: { - name: 'ASC', milestones: { sequenceNo: 'ASC' }, }, }); + + const sorted = [...routes].sort((a, b) => + formatRouteLabel(a).localeCompare(formatRouteLabel(b)), + ); + + const query = filter.search?.trim().toLowerCase(); + if (!query) return sorted; + + return sorted.filter((route) => { + const haystack = [ + formatRouteLabel(route), + route.originYard?.label, + route.originYard?.code, + route.destinationYard?.label, + route.destinationYard?.code, + ...(route.milestones ?? []).map( + (m) => m.yard?.label ?? m.yard?.code ?? m.yardId, + ), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); } async findById(id: string): Promise { @@ -53,16 +75,14 @@ export class RoutesService { } async create(dto: CreateRouteDto): Promise { - await this.validateRouteName(dto.name); const validated = await this.validateMilestones(dto.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( manager.getRepository(Route).create({ - name: dto.name.trim(), originYardId: validated.originYardId, destinationYardId: validated.destinationYardId, - isActive: dto.isActive ?? true, + status: dto.status ?? 'AVAILABLE', }), ); @@ -72,6 +92,7 @@ export class RoutesService { routeId: savedRoute.id, yardId: milestone.yardId, sequenceNo: milestone.sequenceNo, + distanceKm: milestone.distanceKm, }), ), ); @@ -85,20 +106,16 @@ export class RoutesService { async update(id: string, dto: UpdateRouteDto): Promise { const existing = await this.findById(id); - if (dto.name && dto.name.trim() !== existing.name) { - await this.validateRouteName(dto.name, id); - } - const milestoneInput = dto.milestones ? await this.validateMilestones(dto.milestones) : null; await this.dataSource.transaction(async (manager) => { await manager.getRepository(Route).update(id, { - name: dto.name?.trim() ?? existing.name, originYardId: milestoneInput?.originYardId ?? existing.originYardId, - destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, - isActive: dto.isActive ?? existing.isActive, + destinationYardId: + milestoneInput?.destinationYardId ?? existing.destinationYardId, + ...(dto.status !== undefined ? { status: dto.status } : {}), }); if (milestoneInput) { @@ -109,6 +126,7 @@ export class RoutesService { routeId: id, yardId: milestone.yardId, sequenceNo: milestone.sequenceNo, + distanceKm: milestone.distanceKm, }), ), ); @@ -120,7 +138,9 @@ export class RoutesService { async deactivate(id: string): Promise { await this.findById(id); - const updated = await this.routesRepository.update(id, { isActive: false }); + const updated = await this.routesRepository.update(id, { + status: 'STOP_WORKING', + } as never); if (!updated) { throw new NotFoundException(`Route ${id} not found`); @@ -129,27 +149,32 @@ export class RoutesService { return this.findById(id); } - private async validateRouteName(name: string, routeId?: string) { - const trimmedName = name.trim(); - const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); - - if (existing && existing.id !== routeId) { - throw new ConflictException(`Route name ${trimmedName} already exists`); - } - } - - private async validateMilestones(milestones: Array<{ yardId: string }>) { + private async validateMilestones( + milestones: Array<{ yardId: string; distanceKm?: number }>, + ) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => ({ - yardId: milestone.yardId, - sequenceNo: index + 1, - })); + const normalized = milestones.map((milestone, index) => { + const distanceKm = + index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; + if (index > 0 && (distanceKm == null || distanceKm < 0)) { + throw new BadRequestException( + `Enter segment KM for stop ${index + 1} (from previous yard).`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm, + }; + }); const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; - const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); for (const milestone of normalized) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 794f97f83..f30ebd501 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -21,11 +21,6 @@ export class CreateCargoTypeDto { @IsUUID() parentGroupId?: string; - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - showFreeTextBox?: boolean; - @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 2c1ed0e22..c8ac35f25 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -17,9 +17,6 @@ export class CargoType extends BaseEntity { @Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) parentGroupId?: string | null; - @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) - showFreeTextBox!: boolean; - /** * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM * (break-bulk). Nullable for container/legacy cargo, which is counted by diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 16027f9c3..4b082e5fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -331,16 +331,14 @@ export class RuleEngineService { ): Promise { await this.ensureDefaultApprovalRules(); - let requiresDirectorApproval = options.freightType === 'BULK'; + let requiresDirectorApproval = false; if (options.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); if (!cargoType) { throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); } - if (cargoType.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + requiresDirectorApproval = cargoType.requiresDirectorApproval; } const chain = await this.approvalRulesRepo.findChainForCargo( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 5a343f910..f80ada585 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -79,7 +79,6 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, - showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, 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 543646a7d..218a20436 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 @@ -4,24 +4,28 @@ import { Logger, NotFoundException, OnModuleInit, -} from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { Cron, SchedulerRegistry } from "@nestjs/schedule"; -import { DataSource } from "typeorm"; -import { Freight } from "@edr/types"; + Optional, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { formatRouteLabel } from '../routes/entities/route.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { BookingNotifierService } from './booking-notifier.service'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { Freight } from "@edr/types"; import { BillingService } from "../billing/billing.service"; -import { Booking } from "../bookings/entities/booking.entity"; -import { BookingsRepository } from "../bookings/bookings.repository"; -import { Locomotive } from "../locomotives/entities/locomotive.entity"; -import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; -import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; -import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository"; -import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository"; -import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity"; -import { BookingNotifierService } from "./booking-notifier.service"; -import { TrainSchedulingService } from "./train-scheduling.service"; -import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util"; + + import { BATCH_CRON, BATCH_TIMEZONE, @@ -34,8 +38,9 @@ import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, -} from "./train-capacity.util"; -import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +} from './train-capacity.util'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -182,7 +187,10 @@ export class BookingBatchService implements OnModuleInit { private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, - ) { } + + @Optional() private readonly milestoneService?: ClearanceMilestoneService, + + ) {} /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { @@ -578,7 +586,7 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, - routeName: s.route?.name ?? null, + routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, @@ -662,7 +670,7 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, - routeName: s.route?.name ?? null, + routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, @@ -1102,6 +1110,16 @@ export class BookingBatchService implements OnModuleInit { }); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); + void this.markWagonAllocatedMilestone(booking.id); + } + + private async markWagonAllocatedMilestone(bookingId: string): Promise { + if (!this.milestoneService) return; + try { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } catch { + // Booking may have no milestone rows (non-contract path). + } } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 0a56865d5..0f2e22076 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -26,6 +26,7 @@ import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; import { NotificationsModule } from '../notifications/notifications.module'; +import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ @@ -51,6 +52,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; TrainSchedulesModule, forwardRef(() => WarehousesModule), RuleEngineModule, + forwardRef(() => ContractsModule), ], controllers: [TrainSchedulingController], providers: [ 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 c71634ccb..8d4021389 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 @@ -21,7 +21,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -import { Route } from '../routes/entities/route.entity'; +import { formatRouteLabel, Route } from '../routes/entities/route.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; @@ -304,7 +304,7 @@ export class TrainSchedulingService { } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { - const route = await this.getActiveRoute(dto.routeId); + const route = await this.getSchedulableRoute(dto.routeId); const locomotiveIds = [...new Set(dto.locomotiveIds)]; if (locomotiveIds.length < 2) { @@ -949,7 +949,7 @@ export class TrainSchedulingService { generatedAt: generatedAt.toISOString(), trainScheduleId: schedule.id, trainNumber: schedule.trainNumber ?? null, - route: schedule.route?.name ?? null, + route: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, totalBookings: schedule.scheduleBookings?.length ?? 0, @@ -2605,13 +2605,17 @@ export class TrainSchedulingService { return saved; } - private async getActiveRoute(routeId: string) { + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, relations: { originYard: true, destinationYard: true }, }); if (!route) throw new NotFoundException(`Route ${routeId} not found`); - if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`); + if (route.status !== 'AVAILABLE') { + throw new BadRequestException( + `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, + ); + } return route; } @@ -2656,7 +2660,7 @@ export class TrainSchedulingService { id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, - routeName: schedule.route?.name ?? null, + routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, @@ -2691,7 +2695,7 @@ export class TrainSchedulingService { /** AVAILABLE locomotives at the route's origin yard. */ async getAvailableLocomotivesForRoute(routeId: string): Promise { - const route = await this.getActiveRoute(routeId); + const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ where: { status: 'AVAILABLE', currentYardId: route.originYardId }, @@ -2933,7 +2937,9 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, - route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null, + route: schedule.route + ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } + : null, scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledArrivalDate: schedule.scheduledArrivalDate, actualDepartureAt: schedule.actualDepartureAt ?? null, diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 9670243c9..a619c7c05 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -468,15 +468,15 @@ export class DemoBookingsSeeder { const djibouti = yardByCode.get("DJIBOUTI"); const addis = yardByCode.get("ADDIS_ABABA"); if (djibouti && addis) { - const routeName = "Djibouti → Addis Ababa"; - let route = await manager.getRepository(Route).findOneBy({ name: routeName }); + let route = await manager.getRepository(Route).findOne({ + where: { originYardId: djibouti.id, destinationYardId: addis.id }, + }); if (!route) { route = await manager.getRepository(Route).save( manager.getRepository(Route).create({ - name: routeName, originYardId: djibouti.id, destinationYardId: addis.id, - isActive: true, + status: 'AVAILABLE', }), ); await manager.getRepository(RouteMilestone).save([ @@ -484,11 +484,13 @@ export class DemoBookingsSeeder { routeId: route.id, yardId: djibouti.id, sequenceNo: 1, + distanceKm: 0, }), manager.getRepository(RouteMilestone).create({ routeId: route.id, yardId: addis.id, sequenceNo: 2, + distanceKm: 780, }), ]); } 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 ba0d6da19..56ca98626 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -80,6 +80,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'), + perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), + perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), + perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -149,6 +152,9 @@ export const FREIGHT_PERMS = { finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', createBooking: 'edr_freight_app:contracts:create_booking', opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review', + clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', + clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', + clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -237,6 +243,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, @@ -247,6 +255,7 @@ export const ROLE_PERMISSION_PRESETS = { // damage reports. Read-only on the contract; no booking creation. glDjibouti: [ FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index ae48ada66..02a05602e 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -286,15 +286,15 @@ export class PricingDataSeeder { const routeRepo = manager.getRepository(Route); const milestoneRepo = manager.getRepository(RouteMilestone); - const routeName = "Addis Ababa → Dire Dawa"; - let route = await routeRepo.findOneBy({ name: routeName }); + let route = await routeRepo.findOne({ + where: { originYardId: addis.id, destinationYardId: direDawa.id }, + }); if (!route) { route = await routeRepo.save( routeRepo.create({ - name: routeName, originYardId: addis.id, destinationYardId: direDawa.id, - isActive: true, + status: 'AVAILABLE', }), ); await milestoneRepo.save([ @@ -302,11 +302,13 @@ export class PricingDataSeeder { routeId: route.id, yardId: addis.id, sequenceNo: 1, + distanceKm: 0, }), milestoneRepo.create({ routeId: route.id, yardId: direDawa.id, sequenceNo: 2, + distanceKm: 445, }), ]); this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa"); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8c1562bc4..567ecf074 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -15,13 +15,22 @@ import { Send, Settings, ShieldCheck, + Ship, SlidersHorizontal, Train, Truck, Users, Wallet, } from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { + Navigate, + Outlet, + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; import { useAuth } from "./auth/useAuth"; @@ -36,8 +45,12 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; +import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; +import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; -import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; @@ -74,6 +87,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -140,7 +154,22 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Document Clearance", href: "/dashboard/contracts/clearance", icon: , - permission: FREIGHT_PERMS.contracts.clearanceReview, + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, }, { label: "Train Schedules", @@ -386,6 +415,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), + { + label: "Contract validity", + href: "/dashboard/configuration/contract-validity-periods", + }, // { // label: "Train scheduling rules", // href: "/dashboard/configuration/train-scheduling-rules", @@ -502,7 +535,11 @@ const App = () => { /> } + element={ + + + + } /> {/* Contracts (Path A/B) */} @@ -530,11 +567,40 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> {/* GL (Path B) contract clearance review hub */} + } @@ -542,11 +608,34 @@ const App = () => { + } /> + } /> + } /> + + + + } + /> + + + + } + /> {/* Path A ops queue out of scope for now → fold into the GL hub. */} { /> - - - } + element={} /> } /> } /> @@ -902,6 +987,14 @@ const App = () => { } /> + + + + } + /> } /> } /> } /> @@ -930,4 +1023,21 @@ const App = () => { ); }; +/** Redirect removed milestones page to document clearance. */ +function BookingMilestonesRedirect() { + const { id } = useParams(); + return ( + + ); +} + +/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ +function LegacyGlEthiopiaClearanceRedirect() { + const { id } = useParams(); + if (id) { + return ; + } + return ; +} + export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 01a0db69a..13ac9577d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -65,7 +65,6 @@ export function BookingActionsMenu({ }; const hasMenu = listRowHasActions(row, user); - const primary = actions.find((a) => a.primary) ?? actions[0]; if (!hasMenu && variant === "table") { return ( @@ -117,19 +116,6 @@ export function BookingActionsMenu({ onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > - {variant === "table" && primary && ( - - )} - void; /** Hide the inline progress summary (e.g. when the parent renders its own). */ hideSummary?: boolean; + /** Lock approve actions after document review phase completes. */ + approvalsLocked?: boolean; + /** Block new queries after pre-clearance finalization. */ + queriesLocked?: boolean; + /** Read-only audit view — no approve/query actions. */ + readOnly?: boolean; } const STATUS_META: Record< @@ -64,6 +70,9 @@ export function ClearanceReviewSection({ bookingId, onChanged, hideSummary, + approvalsLocked = false, + queriesLocked = false, + readOnly = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -147,6 +156,11 @@ export function ClearanceReviewSection({ return { total, approved, queried, pending, pct }; }, [customerDocs]); + const hasDocsAwaitingApproval = customerDocs.some( + (d) => d.file && d.reviewStatus !== "APPROVED", + ); + const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval; + if (isLoading || !clearance) { return ( @@ -194,6 +208,9 @@ export function ClearanceReviewSection({ @@ -397,6 +414,9 @@ function StatPill({ function DocReviewCard({ doc, + approvalsLocked, + queriesLocked, + readOnly, note, queryOpen, onToggleQuery, @@ -407,6 +427,9 @@ function DocReviewCard({ busy, }: { doc: Freight.ClearanceDocument; + approvalsLocked: boolean; + queriesLocked: boolean; + readOnly: boolean; note: string; queryOpen: boolean; onToggleQuery: (open: boolean) => void; @@ -419,6 +442,7 @@ function DocReviewCard({ const status = doc.reviewStatus ?? "PENDING"; const meta = STATUS_META[status]; const hasFile = !!doc.file; + const isApproved = status === "APPROVED"; return ( )} - {hasFile && ( + {hasFile && !readOnly && ( {!queryOpen ? ( - - + {!queriesLocked && ( + + )} + {!isApproved && !approvalsLocked && ( + + )} ) : ( void; + onDownloadFile?: (file: { id: string; name: string }) => void; +} + +function findMilestone( + milestones: Freight.IClearanceMilestone[] | undefined, + code: string, +): Freight.IClearanceMilestone | undefined { + return milestones?.find((m) => m.milestoneCode === code); +} + +/** + * Document Clearance detail layout: primary clearance workflow plus optional + * uploaded documents, post-booking risk assignment, and incident reporting tabs. + */ +export function ClearanceOpsTabs({ + bookingId, + milestones, + showOpsTabs = true, + clearanceTab, + workflowFiles = [], + showWorkflowFilesTab = false, + tradeDirection = "IMPORT", + onViewFile, + onDownloadFile, +}: ClearanceOpsTabsProps) { + const riskMs = findMilestone(milestones, "RISK_ASSIGNED"); + const hasOps = Boolean(bookingId); + const isExport = tradeDirection === "EXPORT"; + const uploadedDocCount = workflowFiles.filter((f) => { + if (!f.file) return false; + if (isExport) return f.category !== "duty"; + return true; + }).length; + const showDocuments = showWorkflowFilesTab && Boolean(onViewFile); + const hasTabs = (showOpsTabs && hasOps) || showDocuments; + + if (!hasTabs) { + return <>{clearanceTab}; + } + + return ( + + + Clearance + {showDocuments ? ( + } + rightSection={ + uploadedDocCount > 0 ? ( + + {uploadedDocCount} + + ) : undefined + } + > + Uploaded documents + + ) : null} + {showOpsTabs && riskMs ? ( + }> + Risk assignment + + ) : null} + {showOpsTabs && bookingId ? ( + }> + Incidents + + ) : null} + + + {clearanceTab} + + {showDocuments ? ( + + + + ) : null} + + {showOpsTabs && riskMs && bookingId ? ( + + + + + + ) : null} + + {showOpsTabs && bookingId ? ( + + + + + Log container or seal issues discovered during clearance handling. + + + + + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx new file mode 100644 index 000000000..ba93a790d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx @@ -0,0 +1,112 @@ +import { Check } from "lucide-react"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +const BRAND_GREEN = "var(--freight-brand, #0A6F4D)"; + +const IMPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_ET_OUTPUT", + "CUSTOMER_DUTY", + "GL_ET_POST_CLEARANCE", + "GL_DJ_COLLECTION", +] as const; + +const PHASE_LABELS: Record = { + CUSTOMER_INTAKE: "Customer docs", + GL_ET_REVIEW: "GL ET review", + GL_DJ_COLLECTION: "GL Djibouti DO", + GL_ET_OUTPUT: "Declaration", + CUSTOMER_DUTY: "Duty / customer pays", + GL_ET_POST_CLEARANCE: "Transit & finalize", + GL_DJ_LOADING: "Loading", + POST_TRANSIT: "Transit", +}; + +const EXPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_DJ_COLLECTION", + "GL_ET_OUTPUT", + "GL_ET_POST_CLEARANCE", +] as const; + +function phaseIndex(phases: readonly string[], current?: string | null): number { + if (!current) return 0; + const idx = phases.indexOf(current); + return idx >= 0 ? idx : 0; +} + +export function ClearancePhaseStepper({ + clearance, + tradeDirection, + compact = false, +}: { + clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null; + tradeDirection?: string; + compact?: boolean; +}) { + const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES; + const current = clearance?.phase ?? phases[0]; + const activeIdx = phaseIndex(phases, current); + + return ( + + {phases.map((phase, index) => { + const isComplete = index < activeIdx; + const isActive = index === activeIdx; + const isLast = index === phases.length - 1; + + return ( + + + + + {isComplete ? : null} + + + {PHASE_LABELS[phase] ?? phase} + + + {!isLast && ( + + )} + + + ); + })} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx new file mode 100644 index 000000000..1abffa761 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core"; +import { FileText, Receipt, Ship, Truck } from "lucide-react"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; + +type TabValue = Freight.ClearanceWorkflowFileCategory; + +type TabConfig = { + value: TabValue; + label: string; + icon: typeof FileText; + emptyHint: string; +}; + +function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] { + if (tradeDirection === "EXPORT") { + return [ + { + value: "declaration", + label: "Declaration", + icon: FileText, + emptyHint: "No declaration uploaded yet.", + }, + { + value: "djibouti", + label: "Release order", + icon: Ship, + emptyHint: "No release order uploaded yet.", + }, + { + value: "transit", + label: "Transit Permit", + icon: Truck, + emptyHint: "No transit permit uploaded yet.", + }, + ]; + } + + return [ + { + value: "declaration", + label: "Declaration", + icon: FileText, + emptyHint: "No declaration uploaded yet.", + }, + { + value: "duty", + label: "Duty notice", + icon: Receipt, + emptyHint: "No duty notice or payment slip uploaded yet.", + }, + { + value: "transit", + label: "Transit permit", + icon: Truck, + emptyHint: "No transit permit uploaded yet.", + }, + ]; +} + +function subtitleForTradeDirection(tradeDirection: string): string { + return tradeDirection === "EXPORT" + ? "Declaration, release order, and transit permit files for this clearance." + : "Declaration, duty notice, and transit permit files for this clearance."; +} + +function footerHintForTradeDirection(tradeDirection: string): string { + return tradeDirection === "EXPORT" + ? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded." + : "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents."; +} + +export interface ClearanceUploadedDocumentsPanelProps { + files: Freight.ClearanceWorkflowFile[]; + tradeDirection?: string; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +} + +export function ClearanceUploadedDocumentsPanel({ + files, + tradeDirection = "IMPORT", + onView, + onDownload, +}: ClearanceUploadedDocumentsPanelProps) { + const tabConfig = useMemo( + () => tabConfigForTradeDirection(tradeDirection), + [tradeDirection], + ); + const isExport = tradeDirection === "EXPORT"; + + const visibleFiles = useMemo( + () => + isExport ? files.filter((f) => f.category !== "duty") : files, + [files, isExport], + ); + + const uploadedCount = visibleFiles.filter((f) => f.file).length; + + const defaultTab = + tabConfig.find((tab) => + visibleFiles.some((f) => f.category === tab.value && f.file), + )?.value ?? tabConfig[0]?.value ?? "declaration"; + + return ( + + + + {tabConfig.map((tab) => { + const count = visibleFiles.filter( + (f) => f.category === tab.value && f.file, + ).length; + const Icon = tab.icon; + return ( + } + rightSection={ + count > 0 ? ( + + {count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + + {tabConfig.map((tab) => { + const items = visibleFiles.filter( + (f) => f.category === tab.value && f.file, + ); + const Icon = tab.icon; + + return ( + + {items.length > 0 ? ( + + {items.map((item) => ( + + ))} + + ) : ( + + )} + + ); + })} + + + {uploadedCount === 0 ? ( + + {footerHintForTradeDirection(tradeDirection)} + + ) : null} + + ); +} + +function EmptyTabState({ + icon: Icon, + hint, +}: { + icon: typeof FileText; + hint: string; +}) { + return ( + + + + + + + {hint} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx new file mode 100644 index 000000000..17e5e4b70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx @@ -0,0 +1,155 @@ +import { + Badge, + Box, + Button, + Group, + Paper, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { Download, Eye, FileText } from "lucide-react"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { fileViewUrl } from "@/constants/apiConfig"; + +const CATEGORY_LABELS: Record< + Freight.ClearanceWorkflowFileCategory, + string +> = { + declaration: "Declaration", + duty: "Duty & taxes", + transit: "Transit", + djibouti: "Djibouti", +}; + +const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [ + "declaration", + "duty", + "transit", + "djibouti", +]; + +const OWNER_LABELS: Record = { + customer: "Customer", + gl_et: "GL Ethiopia", + gl_dj: "GL Djibouti", +}; + +export interface ClearanceWorkflowFilesPanelProps { + files: Freight.ClearanceWorkflowFile[]; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; + title?: string; +} + +export function ClearanceWorkflowFilesPanel({ + files, + onView, + onDownload, + title = "Customs workflow documents", +}: ClearanceWorkflowFilesPanelProps) { + if (files.length === 0) return null; + + const grouped = CATEGORY_ORDER.map((category) => ({ + category, + label: CATEGORY_LABELS[category], + items: files.filter((f) => f.category === category), + })).filter((g) => g.items.length > 0); + + return ( + + + {grouped.map((group) => ( + + + {group.label} + + + {group.items.map((item) => ( + + ))} + + + ))} + + + ); +} + +function WorkflowFileRow({ + item, + onView, + onDownload, +}: { + item: Freight.ClearanceWorkflowFile; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +}) { + const file = item.file; + if (!file) return null; + + const viewUrl = fileViewUrl(file.id); + const canPreview = isViewable({ name: file.name, url: viewUrl }); + + return ( + + + + + + + + + {item.label} + + + + {OWNER_LABELS[item.uploadedBy]} + + + {file.name} + + + + + + {canPreview ? ( + + + + ) : null} + {onDownload ? ( + + + + ) : null} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 7a3dd897e..0d7adc5be 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,6 +1,14 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Check, ShieldCheck } from "lucide-react"; -import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; +import { + Stack, + Group, + Text, + Badge, + Button, + Box, + Modal, +} from "@mantine/core"; import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; @@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({ contract, mutations, }: ContractApprovalStepsCardProps) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingStep, setPendingStep] = + useState(null); + const steps = useMemo( () => [...(contract.approvalSteps ?? [])].sort( @@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + const openApprove = (step: Freight.IContractApprovalStep) => { + setPendingStep(step); + setConfirmOpen(true); + }; + + const closeApprove = () => { + setConfirmOpen(false); + setPendingStep(null); + }; + + const runApprove = () => { + if (!pendingStep) return; + mutations.approveStep.mutate( + { stepId: pendingStep.id, requiredRole: pendingStep.requiredRole }, + { onSuccess: () => closeApprove() }, + ); + }; + const subtitle = summary.detail || (nextPending @@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({ : "Accept submission to begin"); return ( - - {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} - - } - > - - {subtitle} - - - {steps.length === 0 ? ( - - Use Accept for approval in staff actions to - instantiate steps. + <> + + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} - ) : ( - - {steps.map((step) => ( - - mutations.approveStep.mutate({ - stepId: step.id, - requiredRole: step.requiredRole, - }) - } - /> - ))} + + {steps.length === 0 ? ( + + Use Accept for approval in staff actions to + instantiate steps. + + ) : ( + + {steps.map((step) => ( + openApprove(step)} + /> + ))} + + )} + + + + + + You are about to approve the{" "} + + {pendingStep?.requiredRole} + {" "} + step for contract{" "} + + {contract.reference} + + . This action cannot be undone from this screen. + + + + + - )} - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index c660bb928..36b18bbec 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -54,6 +54,21 @@ export interface ContractClearanceReviewSectionProps { * by whom, when) but hide all approve / query / finalize actions. */ readOnly?: boolean; + /** + * Document approvals are locked (e.g. after all docs approved in phased flow) + * but queries remain available until {@link queriesLocked} or {@link readOnly}. + */ + approvalsLocked?: boolean; + /** + * Pre-clearance finalized — block opening new queries on customer documents. + */ + queriesLocked?: boolean; + /** + * ONE_TIME customs contracts use the phased milestone workflow. Hides the + * legacy "Finalize clearance" shortcut; booking readiness follows delivery + * order (import) or export release. + */ + phasedCustoms?: boolean; } const STATUS_META: Record< @@ -89,6 +104,9 @@ export function ContractClearanceReviewSection({ hideSummary, selfClear = false, readOnly = false, + phasedCustoms = false, + approvalsLocked = false, + queriesLocked = false, }: ContractClearanceReviewSectionProps) { const [queryNotes, setQueryNotes] = useState>({}); const [openQuery, setOpenQuery] = useState>({}); @@ -137,6 +155,11 @@ export function ContractClearanceReviewSection({ .filter((d) => d.file && d.reviewStatus !== "APPROVED") .map((d) => d.fileKey); + const hasDocsAwaitingApproval = customerDocs.some( + (d) => d.file && d.reviewStatus !== "APPROVED", + ); + const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval; + if (isLoading || !clearance) { return ( @@ -170,14 +193,16 @@ export function ContractClearanceReviewSection({ subtitle={ readOnly ? `Reviewed by the ${reviewerTeam} team.` - : "Approve each document, or open a query to tell the customer what to fix." + : effectiveApprovalsLocked + ? "Documents are approved — you can still open a query if something needs fixing." + : "Approve each document, or open a query to tell the customer what to fix." } extra={ {stats.approved}/{stats.total} approved - {!readOnly && approvableKeys.length > 0 && ( + {!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && ( - + {!queriesLocked && ( + + )} + {!isApproved && !approvalsLocked && ( + + )} ) : ( void; + reference: string; + message?: string; + confirmLabel?: string; +} + +export function ContractSignSuccessModal({ + opened, + onClose, + reference, + message = "The contract has been signed and recorded.", + confirmLabel = "Back to contract request", +}: ContractSignSuccessModalProps) { + return ( + + + + + + {reference} + + {message} + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx new file mode 100644 index 000000000..b33729bf1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { Ship, Upload } from "lucide-react"; +import toast from "react-hot-toast"; + +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; +import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; + +export type GlClearanceUploadKind = "do" | "ro"; + +export interface GlClearanceUploadModalProps { + opened: boolean; + kind: GlClearanceUploadKind | null; + onClose: () => void; + entityId: string; + isBooking: boolean; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + vesselDepartureDate?: string | null; + onSuccess?: () => void; + onPreview?: (file: { name: string; url: string }) => void; +} + +export function GlClearanceUploadModal({ + opened, + kind, + onClose, + entityId, + isBooking, + workflowFiles = [], + vesselDepartureDate, + onSuccess, + onPreview, +}: GlClearanceUploadModalProps) { + const [file, setFile] = useState(null); + const [vesselDate, setVesselDate] = useState( + vesselDepartureDate ? new Date(vesselDepartureDate) : null, + ); + const [loading, setLoading] = useState(false); + + const isDo = kind === "do"; + const isRo = kind === "ro"; + const replaceMode = isDo + ? Boolean(findWorkflowFile(workflowFiles, "delivery_order")) + : Boolean(findWorkflowFile(workflowFiles, "release_order")); + + const close = () => { + setFile(null); + onClose(); + }; + + const submit = async () => { + if (!file || !kind) return; + if (isRo && !vesselDate) { + toast.error("Vessel departure date is required."); + return; + } + + setLoading(true); + try { + if (isDo) { + if (isBooking) { + await bookingsService.uploadDeliveryOrder(entityId, file); + } else { + await contractsService.uploadDeliveryOrder(entityId, file); + } + toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); + } else { + const iso = vesselDate!.toISOString().slice(0, 10); + const result = isBooking + ? await bookingsService.uploadReleaseOrder(entityId, file, iso) + : await contractsService.uploadReleaseOrder(entityId, file, iso); + if (result.hold) { + toast.error(result.holdReason ?? "Vessel date too soon"); + } else { + toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded"); + } + } + setFile(null); + onSuccess?.(); + close(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + {isDo ? "Upload Delivery Order" : "Upload Release Order"} + + } + radius="md" + size="md" + > + + + {isDo + ? "Upload the Djibouti Delivery Order (DO) for this import shipment." + : "Upload the Release Order and confirm the vessel departure date."} + + + {isRo ? ( + setVesselDate(v ? new Date(v) : null)} + size="sm" + required + /> + ) : null} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 676e4840e..f9bd7683d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, @@ -6,14 +6,11 @@ import { } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { - ActionIcon, Alert, - Badge, Box, Button, Center, Divider, - Grid, Group, Loader, Modal, @@ -28,13 +25,13 @@ import { } from "@mantine/core"; import { AlertCircle, + CalendarDays, CheckCircle2, - Container as ContainerIcon, + ChevronLeft, FileText, + MapPin, Package, - Plus, Receipt, - Trash2, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -43,19 +40,23 @@ import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { contractsService } from "@/services/contracts.service"; import { - useContractCapacity, useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; -import { Boxes } from "lucide-react"; import { computeGlShipmentTotal, formatRateUnit, type GlShipmentQuantities, } from "./gl-booking-form/total"; +import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice"; +import { + fieldStyles, + StepCard, + StepHeader, + StepLabel, +} from "./gl-booking-form/form-ui"; interface UnitDraft { containerNumber: string; @@ -75,22 +76,28 @@ interface BulkLineDraft { cargoWeightTons: number | string; itemCount: number | string; hazardousQuantity: number | string; + reeferQuantity: number | string; } function emptyUnit(): UnitDraft { return { containerNumber: "", sealNumber: "", vgmTons: "" }; } +function bulkUnitOfMeasure( + contract: Freight.IContract, +): "PER_TON" | "PER_ITEM" { + const hasPerItem = contract.pricingBreakdown?.lineItems?.some( + (li) => li.unit === "per_item", + ); + return hasPerItem ? "PER_ITEM" : "PER_TON"; +} + export default function GlCreateBookingForm() { const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); - // When GL accepts a shipment request, the form opens with ?requestId=… so it - // can prefill the requested quantities/date and mark the request accepted on - // success. const requestId = searchParams.get("requestId"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); - const { data: capacity = [] } = useContractCapacity(id); const mutations = useContractMutations(id ?? ""); const { data: bookingRequest } = useQuery({ @@ -105,42 +112,8 @@ export default function GlCreateBookingForm() { const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); const [prefilled, setPrefilled] = useState(false); - - // Prefill once from an accepted shipment request: size/qty container lines - // (one blank unit per requested container) + bulk + route + notes. GL still - // enters per-unit container numbers + sets the binding shipment date. - useEffect(() => { - if (!bookingRequest || prefilled) return; - setPrefilled(true); - const lines = bookingRequest.requestedLines ?? {}; - if (lines.containers?.length) { - setContainerLines( - lines.containers.map((c) => ({ - containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), - })), - ); - } else if (lines.bulk) { - setBulkLines([ - { - cargoTypeId: lines.bulk.cargoTypeId ?? "", - cargoWeightTons: lines.bulk.cargoWeightTons ?? "", - itemCount: lines.bulk.itemCount ?? "", - hazardousQuantity: lines.bulk.hazardousQuantity ?? "", - }, - ]); - } - if (bookingRequest.contractRouteId) - setContractRouteId(bookingRequest.contractRouteId); - if (bookingRequest.notes) setNotes(bookingRequest.notes); - }, [bookingRequest, prefilled]); - // Price-confirm modal — GL reviews the estimate before booking on behalf of - // the customer, mirroring the portal customer flow. const [priceOpen, setPriceOpen] = useState(false); + const seededRef = useRef(false); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -158,7 +131,6 @@ export default function GlCreateBookingForm() { return [...sizes]; }, [contract?.cargoScope]); - // Bulk cargo types declared on the contract scope (prefill, no free-text). const bulkCargoOptions = useMemo(() => { const seen = new Map(); (contract?.cargoScope ?? []).forEach((s) => { @@ -172,11 +144,73 @@ export default function GlCreateBookingForm() { const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? ""; - // Normalized quantities for the client-side price estimate (same source the - // portal customer sees: the contract's frozen unit rates × entered qty). + useEffect(() => { + if (!bookingRequest || prefilled) return; + setPrefilled(true); + const lines = bookingRequest.requestedLines ?? {}; + if (lines.containers?.length) { + setContainerLines( + lines.containers.map((c) => ({ + containerSize: c.containerSize, + hazardousQuantity: c.hazardousQuantity ?? "0", + reeferQuantity: c.reeferQuantity ?? "", + units: Array.from({ length: Math.max(1, c.quantity) }, () => + emptyUnit(), + ), + })), + ); + } else if (lines.bulk) { + setBulkLines([ + { + cargoTypeId: lines.bulk.cargoTypeId ?? defaultBulkCargoTypeId, + cargoWeightTons: lines.bulk.cargoWeightTons ?? "", + itemCount: lines.bulk.itemCount ?? "", + hazardousQuantity: lines.bulk.hazardousQuantity ?? "0", + reeferQuantity: "", + }, + ]); + } + if (bookingRequest.contractRouteId) + setContractRouteId(bookingRequest.contractRouteId); + if (bookingRequest.notes) setNotes(bookingRequest.notes); + }, [bookingRequest, prefilled, defaultBulkCargoTypeId]); + + useEffect(() => { + if (!contract || prefilled || seededRef.current) return; + seededRef.current = true; + if (isContainer && containerSizes.length > 0 && containerLines.length === 0) { + setContainerLines( + containerSizes.map((size) => ({ + containerSize: size, + hazardousQuantity: "0", + reeferQuantity: "0", + units: [emptyUnit()], + })), + ); + } else if (!isContainer && bulkLines.length === 0) { + setBulkLines([ + { + cargoTypeId: defaultBulkCargoTypeId, + cargoWeightTons: "", + itemCount: "", + hazardousQuantity: "0", + reeferQuantity: "0", + }, + ]); + } + }, [ + contract, + prefilled, + isContainer, + containerSizes, + containerLines.length, + bulkLines.length, + defaultBulkCargoTypeId, + ]); + const quantities: GlShipmentQuantities = useMemo( () => ({ - isContainer, + isContainer: Boolean(isContainer), containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, @@ -200,17 +234,11 @@ export default function GlCreateBookingForm() { [contract, quantities], ); - // The route this shipment ships on (for the cargo-aware day list). For a - // single-route contract there's exactly one; for GENERAL multi-route, the - // selected route (defaults to the first). const selectedRoute = useMemo( () => routes.find((r) => r.id === contractRouteId) ?? routes[0], [routes, contractRouteId], ); - // Cargo-aware availability query: only days where a train has remaining - // capacity AND enough matching-type wagons for the entered cargo. Null until - // the cargo is entered (so the Schedule section stays empty first). const cargoQuery = useMemo(() => { if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId) return null; @@ -247,18 +275,113 @@ export default function GlCreateBookingForm() { const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.trainScheduling.availableDaysForCargo.queryOptions({ - input: cargoQuery ?? { - freightType: "BULK" as const, - }, + input: cargoQuery ?? { freightType: "BULK" as const }, }), enabled: cargoQuery !== null, }); + const syncUnits = (lineIdx: number, qty: number) => { + setContainerLines((prev) => + prev.map((line, i) => { + if (i !== lineIdx) return line; + const next = [...line.units]; + while (next.length < qty) next.push(emptyUnit()); + next.length = Math.max(0, qty); + return { ...line, units: next }; + }), + ); + }; + + const patchLine = (idx: number, patch: Partial) => + setContainerLines((prev) => + prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), + ); + + const patchUnit = ( + lineIdx: number, + unitIdx: number, + patch: Partial, + ) => + patchLine(lineIdx, { + units: containerLines[lineIdx].units.map((u, i) => + i === unitIdx ? { ...u, ...patch } : u, + ), + }); + + const patchBulk = (idx: number, patch: Partial) => + setBulkLines((prev) => + prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), + ); + + const canSubmit = + Boolean(scheduledDate) && + (!needsRouteSelect || Boolean(contractRouteId)) && + (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); + + const handleSubmit = () => { + if (!scheduledDate || !contract) return; + + const payload: Freight.CreateBookingUnderContractDto = { + scheduledDate, + ...(contractRouteId ? { contractRouteId } : {}), + ...(notes.trim() ? { notes: notes.trim() } : {}), + }; + + if (isContainer) { + payload.containers = containerLines + .filter((l) => l.units.length > 0) + .map((l) => ({ + containerSize: l.containerSize, + quantity: l.units.length, + ...(l.hazardousQuantity !== "" + ? { hazardousQuantity: Number(l.hazardousQuantity) } + : {}), + ...(l.reeferQuantity !== "" + ? { reeferQuantity: Number(l.reeferQuantity) } + : {}), + units: l.units.map((u) => ({ + containerNumber: u.containerNumber, + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + })), + })); + } else { + payload.bulkLines = bulkLines.map((l) => ({ + ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), + ...(l.cargoWeightTons !== "" + ? { cargoWeightTons: Number(l.cargoWeightTons) } + : {}), + ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), + ...(l.hazardousQuantity !== "" + ? { hazardousQuantity: Number(l.hazardousQuantity) } + : {}), + ...(l.reeferQuantity !== "" + ? { reeferQuantity: Number(l.reeferQuantity) } + : {}), + })); + } + + mutations.createBooking.mutate(payload, { + onSuccess: async (booking) => { + if (requestId) { + try { + await contractsService.acceptBookingRequest(requestId, booking.id); + } catch { + // Non-fatal + } + navigate(`/dashboard/bookings/${booking.id}/clearance`); + } else { + navigate(`/dashboard/contracts/clearance/${contract.id}`); + } + }, + }); + }; + if (isLoading) { return (
- +
); @@ -275,194 +398,74 @@ export default function GlCreateBookingForm() { ); } - // ── Container line helpers ── - const addContainerLine = () => - setContainerLines((prev) => [ - ...prev, - { - containerSize: containerSizes[0] ?? "20ft", - hazardousQuantity: "", - reeferQuantity: "", - units: [emptyUnit()], - }, - ]); - const removeContainerLine = (idx: number) => - setContainerLines((prev) => prev.filter((_, i) => i !== idx)); - const patchLine = (idx: number, patch: Partial) => - setContainerLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - const addUnit = (lineIdx: number) => - patchLine(lineIdx, { - units: [...containerLines[lineIdx].units, emptyUnit()], - }); - const removeUnit = (lineIdx: number, unitIdx: number) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx), - }); - const patchUnit = ( - lineIdx: number, - unitIdx: number, - patch: Partial, - ) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.map((u, i) => - i === unitIdx ? { ...u, ...patch } : u, - ), - }); - - // ── Bulk line helpers ── - const addBulkLine = () => - setBulkLines((prev) => [ - ...prev, - { - cargoTypeId: defaultBulkCargoTypeId, - cargoWeightTons: "", - itemCount: "", - hazardousQuantity: "", - }, - ]); - const removeBulkLine = (idx: number) => - setBulkLines((prev) => prev.filter((_, i) => i !== idx)); - const patchBulk = (idx: number, patch: Partial) => - setBulkLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - - const canSubmit = - Boolean(scheduledDate) && - (!needsRouteSelect || Boolean(contractRouteId)) && - (isContainer ? containerLines.length > 0 : bulkLines.length > 0); - - const handleSubmit = () => { - if (!scheduledDate) return; - - const payload: Freight.CreateBookingUnderContractDto = { - scheduledDate, - ...(contractRouteId ? { contractRouteId } : {}), - ...(notes.trim() ? { notes: notes.trim() } : {}), - }; - - if (isContainer) { - payload.containers = containerLines.map((l) => ({ - containerSize: l.containerSize, - quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), - units: l.units.map((u) => ({ - containerNumber: u.containerNumber, - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), - vgmTons: Number(u.vgmTons) || 0, - })), - })); - } else { - payload.bulkLines = bulkLines.map((l) => ({ - ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), - ...(l.cargoWeightTons !== "" - ? { cargoWeightTons: Number(l.cargoWeightTons) } - : {}), - ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - })); - } - - mutations.createBooking.mutate(payload, { - onSuccess: async (booking) => { - if (requestId) { - // GENERAL+customs accept flow: mark the request accepted + link the - // booking, then hand off to the per-booking clearance review. - try { - await contractsService.acceptBookingRequest(requestId, booking.id); - } catch { - // Non-fatal — the booking exists; the request link can be retried. - } - navigate(`/dashboard/clearance/${booking.id}`); - } else { - navigate(`/dashboard/bookings/${booking.id}/milestones`); - } - }, - }); - }; + const bulkUom = bulkUnitOfMeasure(contract); return ( - + + + + New Shipment Booking + + + Book a shipment on behalf of the customer for contract {contract.reference}. + + + + - - {capacity.length > 0 && ( - c.remaining === 0) ? "red" : "blue"} - variant="light" - radius="md" - icon={} - title="Contract draw-down capacity" - > - - {capacity.map((c, i) => ( - - {c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left - - ))} - - - )} - {bookingRequest ? ( - } - title="From shipment request" - > - Booking on behalf of the customer for request{" "} - {bookingRequest.reference}. - {bookingRequest.scheduledDate ? ( - <> - {" "} - Customer requested{" "} - - {new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(new Date(bookingRequest.scheduledDate))} - {" "} - — set the binding shipment date below. - - ) : null} - - ) : null} - + {bookingRequest ? ( + } + title="From shipment request" + mb="lg" + > + Booking on behalf of the customer for request{" "} + {bookingRequest.reference}. + {bookingRequest.scheduledDate ? ( + <> + {" "} + Customer requested{" "} + + {new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(bookingRequest.scheduledDate))} + {" "} + — set the binding shipment date below. + + ) : null} + + ) : null} + + + + } + title="Route" + description={ + needsRouteSelect + ? "Choose which contracted route this shipment ships on." + : "This shipment ships on the contract's only route." + } + /> {needsRouteSelect ? ( - patchLine(lineIdx, { - containerSize: v ?? line.containerSize, - }) - } - data={ - containerSizes.length > 0 - ? containerSizes - : ["20ft", "40ft"] - } - /> - - + + {line.containerSize} containers + + + syncUnits(lineIdx, Number(v) || 0)} + radius={10} + styles={fieldStyles} + /> + {contract.isHazardous ? ( patchLine(lineIdx, { hazardousQuantity: v }) } + radius={10} + styles={fieldStyles} /> - - + ) : null} + {contract.isReefer ? ( patchLine(lineIdx, { reeferQuantity: v }) } + radius={10} + styles={fieldStyles} /> - - - - - - - {line.units.map((unit, unitIdx) => ( - - - - patchUnit(lineIdx, unitIdx, { - containerNumber: e.currentTarget.value, - }) - } - /> - - - - patchUnit(lineIdx, unitIdx, { - sealNumber: e.currentTarget.value, - }) - } - /> - - - - patchUnit(lineIdx, unitIdx, { vgmTons: v }) - } - /> - - - removeUnit(lineIdx, unitIdx)} - aria-label="Remove unit" - > - - - - - ))} - - -
- ))} - - )} - - ) : ( - } - onClick={addBulkLine} - > - Add line - - } - > - {bulkLines.length === 0 ? ( - - Add at least one bulk line. - - ) : ( - - {bulkLines.map((line, idx) => ( - - - - Line {idx + 1} - - removeBulkLine(idx)} - aria-label="Remove line" - > - - + ) : null} - - - {bulkCargoOptions.length > 0 ? ( - patchBulk(idx, { cargoTypeId: v ?? "" })} + data={bulkCargoOptions} + radius={10} + styles={fieldStyles} + /> + ) : null} + {bulkUom === "PER_TON" ? ( + patchBulk(idx, { cargoWeightTons: v })} + radius={10} + styles={fieldStyles} + /> + ) : ( + patchBulk(idx, { itemCount: v })} + radius={10} + styles={fieldStyles} + /> + )} + {contract.isHazardous ? ( + patchBulk(idx, { hazardousQuantity: v })} + radius={10} + styles={fieldStyles} + /> + ) : null} + {contract.isReefer ? ( + patchBulk(idx, { reeferQuantity: v })} + radius={10} + styles={fieldStyles} + /> + ) : null} + + ))} + + )} - + + } + title="Schedule" + description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." + /> {cargoQuery === null ? ( } > - Enter the cargo details first — available shipment days depend on - the wagons the cargo needs. + Enter your cargo details first — available shipment days depend on + the wagons your cargo needs. ) : ( - <> - {bookingRequest?.scheduledDate ? ( - - Customer requested{" "} - {new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(new Date(bookingRequest.scheduledDate))}{" "} - — pick the binding shipment day below. - - ) : null} - - + + Shipment day * + + + + )} - + - +