From 9c18d086d7659966be0caf83f5c8a7aa63da870f Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 1 Jul 2026 20:55:17 +0300 Subject: [PATCH] changes --- .../1829000000002-BookingClearanceMeta.ts | 41 + ...0000000000-DropCargoTypeShowFreeTextBox.ts | 19 + .../1830000000001-RouteStatusAndSegmentKm.ts | 72 ++ .../1830000000002-PreClearanceFinalizedAt.ts | 23 + .../booking-reference-data.service.ts | 1 - .../booking-transition.accept.spec.ts | 2 + .../booking-transition.clearance.spec.ts | 6 + .../booking-transition.operation.spec.ts | 2 + .../bookings/booking-transition.service.ts | 62 +- .../modules/bookings/bookings.controller.ts | 177 +++- .../src/modules/bookings/bookings.module.ts | 3 + .../modules/bookings/bookings.repository.ts | 9 + .../dto/booking-reference-data.dto.ts | 3 - .../bookings/entities/booking.entity.ts | 19 + .../booking-clearance.service.spec.ts | 188 ++++ .../contracts/booking-clearance.service.ts | 632 +++++++++++++ .../contracts/clearance-milestone.service.ts | 38 +- .../clearance-workflow.service.spec.ts | 51 +- .../contracts/clearance-workflow.service.ts | 246 ++++- .../contracts/clearance-workflow.types.ts | 33 + .../contracts/contract-booking.service.ts | 8 +- .../contracts/contract-clearance.service.ts | 192 +++- .../contracts/contract-transition.service.ts | 10 +- .../modules/contracts/contracts.controller.ts | 50 +- .../src/modules/contracts/contracts.module.ts | 3 + .../modules/contracts/contracts.repository.ts | 1 + .../contract-clearance-cycle.entity.ts | 4 + .../contracts/phased-clearance.util.ts | 61 ++ .../modules/routes/dto/create-route.dto.ts | 30 +- .../modules/routes/dto/filter-routes.dto.ts | 14 +- .../routes/entities/route-milestone.entity.ts | 4 + .../modules/routes/entities/route.entity.ts | 29 +- .../src/modules/routes/routes.service.ts | 93 +- .../rule-engine/dto/create-cargo-type.dto.ts | 5 - .../rule-engine/entities/cargo-type.entity.ts | 3 - .../rule-engine/rule-engine.service.ts | 6 +- .../services/cargo-types.service.ts | 1 - .../train-scheduling/booking-batch.service.ts | 5 +- .../train-scheduling.service.ts | 22 +- .../src/seed/demo-bookings.seeder.ts | 10 +- .../src/seed/pricing-data.seeder.ts | 10 +- apps/edr-freight-web/backoffice/src/App.tsx | 137 +-- .../contracts/ClearancePhaseStepper.tsx | 24 +- .../contracts/ClearanceWorkflowFilesPanel.tsx | 155 ++++ .../contracts/ContractApprovalStepsCard.tsx | 159 +++- .../ContractClearanceReviewSection.tsx | 61 +- .../contracts/GlCreateBookingForm.tsx | 2 +- .../contracts/PhasedClearanceActionPanel.tsx | 858 +++++++++++++----- .../detail/ContractDetailTabCards.tsx | 8 +- .../trainScheduling/AllocateBookingWizard.tsx | 9 +- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 14 + .../features/contracts/mapShipmentListRow.ts | 64 ++ .../src/hooks/bookings/useBookings.ts | 16 + .../src/hooks/contracts/useContracts.ts | 5 +- .../bookings/DocumentClearanceDetailPage.tsx | 49 +- .../src/pages/bookings/NewBookingPage.tsx | 11 +- .../contracts/ContractClearanceDetailPage.tsx | 135 ++- .../contracts/ContractClearanceListPage.tsx | 100 +- .../contracts/ContractRequestDetailPage.tsx | 62 +- .../pages/contracts/GlClearanceDetailPage.tsx | 126 --- .../contracts/GlDjiboutiClearanceListPage.tsx | 63 -- .../contracts/GlEthiopiaClearanceListPage.tsx | 63 -- .../pages/contracts/ShipmentRequestsPage.tsx | 186 +++- .../backoffice/src/pages/fleet/RoutesPage.tsx | 370 +++++--- .../src/pages/ruleEngine/CargoTypesPage.tsx | 15 - .../src/pages/ruleEngine/config/resources.ts | 1 - .../TrainScheduleV2ListPage.tsx | 12 +- .../backoffice/src/services/api.ts | 10 +- .../src/services/bookings.service.ts | 94 ++ .../src/services/contracts.service.ts | 21 +- .../backoffice/src/services/routes.service.ts | 36 +- apps/edr-freight-web/portal/src/App.tsx | 5 + .../ClearanceWorkflowFilesSection.tsx | 133 +++ .../ContractClearanceAction.tsx | 77 ++ .../ContractCustomerAction.tsx | 97 ++ .../customer-actions/ModalSafeWrapper.tsx | 15 + .../deriveContractCustomerAction.ts | 190 ++++ .../portal/src/constants/URLS.ts | 3 + .../src/pages/MyPortalPage/MyPortalPage.tsx | 8 + .../portal/src/pages/MyPortalPage/actions.ts | 41 +- .../components/ActionNeededSection.tsx | 75 +- .../components/RecentContractsSection.tsx | 57 +- .../BookingClearanceWorkflowBanner.tsx | 158 ++++ .../components/ClearanceCard.tsx | 4 +- .../src/pages/bookings/EditBookingPage.tsx | 15 +- .../src/pages/bookings/NewBookingPage.tsx | 5 +- .../pages/bookings/new-booking-form/schema.ts | 18 + .../new-booking-form/step2-service-type.tsx | 21 +- .../new-booking-form/step5-cargo-details.tsx | 2 +- .../pages/bookings/payments/PayNowButton.tsx | 5 +- .../pages/contracts/ClearancePhaseStepper.tsx | 6 +- .../contracts/ContractClearancePanel.tsx | 11 + .../ContractClearanceWorkflowBanner.tsx | 61 +- .../pages/contracts/ContractDetailPage.tsx | 141 ++- .../src/pages/contracts/ContractsList.tsx | 122 +-- .../src/pages/contracts/NewShipmentPage.tsx | 114 ++- .../contracts/NewShipmentRequestPage.tsx | 146 +++ .../contracts/contract-booking-action.ts | 17 +- .../contracts/new-contract-form/helpers.ts | 1 + .../contracts/new-contract-form/schema.ts | 6 + .../new-contract-form/step2-service-type.tsx | 14 +- .../new-contract-form/step3-cargo-scope.tsx | 18 + .../new-contract-form/step8-review.tsx | 5 + .../contracts/new-shipment-form/schema.ts | 137 ++- .../contracts/new-shipment-form/total.ts | 26 + .../portal/src/services/bookings.service.ts | 12 + .../portal/src/services/contracts.service.ts | 18 + .../src/freight/clearance-files.catalog.ts | 54 ++ packages/types/src/freight/contracts.ts | 9 + packages/types/src/freight/index.ts | 22 +- .../OperationDatePicker.tsx | 99 +- 112 files changed, 5654 insertions(+), 1370 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts create mode 100644 apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts create mode 100644 apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts create mode 100644 apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlEthiopiaClearanceListPage.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ClearanceWorkflowFilesSection.tsx create mode 100644 apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx create mode 100644 apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx create mode 100644 apps/edr-freight-web/portal/src/components/customer-actions/ModalSafeWrapper.tsx create mode 100644 apps/edr-freight-web/portal/src/components/customer-actions/deriveContractCustomerAction.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx create mode 100644 packages/types/src/freight/clearance-files.catalog.ts 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 2ebceeabc..444cb8678 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 @@ -25,6 +25,9 @@ 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'; @Injectable() export class BookingTransitionService { @@ -42,8 +45,16 @@ export class BookingTransitionService { private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, + @Inject(forwardRef(() => BookingClearanceService)) + private readonly bookingClearanceService: BookingClearanceService, + @Inject(forwardRef(() => ClearanceWorkflowService)) + private readonly workflowService: ClearanceWorkflowService, ) {} + private isPhasedGeneralCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); @@ -520,8 +531,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); @@ -671,6 +693,17 @@ 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.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } + return this.bookingsService.findById(bookingId); } @@ -746,8 +779,30 @@ export class BookingTransitionService { 'CHANGES_REQUESTED', staffId, ); + if (this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt) { + await this.bookingsRepository.update(bookingId, { + preClearanceFinalizedAt: null, + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } 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.). */ @@ -781,6 +836,11 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + 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); 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 0f413199b..d835c1ecf 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -12,6 +12,7 @@ import { Request, Res, UnauthorizedException, + UploadedFile, UploadedFiles, UseInterceptors, } from '@nestjs/common'; @@ -19,7 +20,7 @@ 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'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -33,6 +34,11 @@ import type { Response } from 'express'; 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'; @@ -72,6 +78,7 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, + private readonly bookingClearanceService: BookingClearanceService, ) {} @Post() @@ -344,6 +351,20 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── + @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)', @@ -451,6 +472,160 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @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(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadTransitPermit( + id, + file, + 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' }) 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 8be7325ba..dcdd1cea2 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -38,6 +38,7 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi 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'; @Module({ imports: [ @@ -54,6 +55,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 87eea031b..b99611c35 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -490,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/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/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index ad40c731e..60e3314bf 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 @@ -447,6 +447,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..4774aa200 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -0,0 +1,632 @@ +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 { assertDeclarationFiles, buildWorkflowFiles } 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 documentFileKeys = new Set(documents.map((d) => d.fileKey)); + const workflowFiles = buildWorkflowFiles( + files, + booking.tradeDirection ?? 'IMPORT', + documentFileKeys, + ); + + 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'); + } + assertDeclarationFiles(files, tradeDirection); + + for (const file of files) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: file.fieldname, + file, + }); + } + + 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, + file: 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 (!file) throw new BadRequestException('No transit permit uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'transit_permitted', + file, + }); + + 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([ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + const next = this.workflowService.computeNextActionForBooking(b, milestones); + if (next?.actor === 'GL_ET') filtered.push(b); + } + return filtered; + } + + async djQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + const pending = this.workflowService.djPendingMilestoneCodes(milestones); + if (b.roHoldReason || pending || this.workflowService.computeNextActionForBooking(b, milestones)?.actor === 'GL_DJ') { + filtered.push(b); + } + } + return filtered; + } +} 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 0edae5ea0..7add860d2 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(); @@ -199,6 +208,27 @@ export class ClearanceMilestoneService { 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, @@ -212,7 +242,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(); 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 index c5bd1e1d9..f30b64597 100644 --- 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 @@ -5,6 +5,7 @@ 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, @@ -37,15 +38,18 @@ function makeService(milestones: ClearanceMilestone[]) { }; 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 }; + return { service, milestoneService, contractsRepository, bookingsRepository }; } const importContract = { @@ -199,7 +203,7 @@ describe('ClearanceWorkflowService', () => { expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); }); - it('prompts DJ for DO then ET booking when pre-booking complete', () => { + it('prompts ET to finalize pre-clearance after transit permit', () => { const milestones = [ ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), @@ -211,6 +215,26 @@ describe('ClearanceWorkflowService', () => { ]; 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'); @@ -281,4 +305,27 @@ describe('ClearanceWorkflowService', () => { ).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 index 44bb6c8a7..b8e1d81ca 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -8,6 +8,10 @@ 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'; @@ -29,19 +33,45 @@ 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 milestones = await this.listMilestones(contractId); const m = milestones.find((x) => x.milestoneCode === code); return m?.status === 'COMPLETED'; } @@ -59,14 +89,38 @@ export class ClearanceWorkflowService { 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 milestones = await this.listMilestones(contractId); 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; @@ -85,6 +139,12 @@ export class ClearanceWorkflowService { } } + async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForBooking(bookingId, code); + } + } + async completeMilestone( contractId: string, code: string, @@ -102,6 +162,23 @@ export class ClearanceWorkflowService { 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; @@ -109,24 +186,52 @@ export class ClearanceWorkflowService { 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'); + } + 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, { @@ -144,37 +249,92 @@ export class ClearanceWorkflowService { } } + /** 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 { - if (cycle?.currentPhase) { - return cycle.currentPhase as 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.inferPhase(contract, cycle, milestones); + 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 = - contract.tradeDirection === 'IMPORT' + tradeDirection === 'IMPORT' ? isDone(IMPORT_DOC_UPLOADED) : isDone(EXPORT_DOC_UPLOADED); if (!docUploaded) return ContractDocPhase.CustomerIntake; if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; - if (contract.tradeDirection === 'EXPORT') { + if (tradeDirection === 'EXPORT') { if (!isDone('RELEASE_ORDER_SECURED')) { - if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection; return ContractDocPhase.GlDjCollection; } if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; @@ -182,12 +342,12 @@ export class ClearanceWorkflowService { return ContractDocPhase.GlEtPostClearance; } - // Import if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; - if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { + 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; } @@ -197,12 +357,42 @@ export class ClearanceWorkflowService { cycle: ContractClearanceCycle | null, milestones: ClearanceMilestone[], ): ClearanceNextAction | null { - if (cycle?.roHoldReason) { + 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: cycle.roHoldReason, + blockedReason: meta.roHoldReason, }; } @@ -217,7 +407,7 @@ export class ClearanceWorkflowService { }; const docCode = - contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; if (pending(docCode) || !isDone(docCode)) { return { @@ -235,7 +425,12 @@ export class ClearanceWorkflowService { }; } - if (contract.tradeDirection === 'EXPORT') { + const terminalAction = + terminalScope === 'contract' + ? 'Create shipment booking' + : 'Proceed to request operation'; + + if (tradeDirection === 'EXPORT') { if (!isDone('RELEASE_ORDER_SECURED')) { return { actor: 'GL_DJ', @@ -258,13 +453,12 @@ export class ClearanceWorkflowService { }; } return { - actor: 'GL_ET', - action: 'Create shipment booking', + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, milestoneCode: EXPORT_BOUNDARY, }; } - // Import if (!isDone('DECLARED')) { return { actor: 'GL_ET', @@ -273,7 +467,7 @@ export class ClearanceWorkflowService { }; } - if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) { + if (meta.dutyRequired === null || meta.dutyRequired === undefined) { return { actor: 'GL_ET', action: 'Set whether duty/tax applies', @@ -281,7 +475,7 @@ export class ClearanceWorkflowService { }; } - if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) { + if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) { if (!isDone('DUTY_TAXES_ADVISED')) { return { actor: 'GL_ET', @@ -304,6 +498,14 @@ export class ClearanceWorkflowService { }; } + if (!meta.preClearanceFinalizedAt) { + return { + actor: 'GL_ET', + action: 'Finalize pre-clearance', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + if (!isDone(IMPORT_BOUNDARY)) { return { actor: 'GL_DJ', @@ -313,19 +515,17 @@ export class ClearanceWorkflowService { } return { - actor: 'GL_ET', - action: 'Create shipment booking', + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, milestoneCode: IMPORT_BOUNDARY, }; } - /** Contracts where the next pending milestone is owned by ET. */ etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET'); return pending?.milestoneCode ?? null; } - /** Contracts where the next pending milestone is owned by DJ. */ 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 f8ed894c8..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 @@ -196,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, 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 972212e6d..7a6ad3c4e 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 @@ -9,10 +9,12 @@ import { ContractsService, PaginatedContracts } from './contracts.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 { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -63,6 +65,14 @@ export interface ContractClearanceView { vesselDepartureDate?: string | null; roAmendmentRequestedAt?: string | null; bookingReady?: boolean; + preClearanceFinalized?: boolean; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; } @Injectable() @@ -77,18 +87,49 @@ export class ContractClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, ) {} + private isPhasedCustoms(contract: Contract): boolean { + return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME'; + } + private assertPhasedCustoms(contract: Contract): void { - if (!contract.customsClearingEnabled) { - throw new BadRequestException('Phased clearance applies only to customs contracts.'); + if (!this.isPhasedCustoms(contract)) { + throw new BadRequestException( + 'Phased clearance (Phase 1) applies to one-time customs contracts.', + ); } - if (contract.contractKind !== 'ONE_TIME') { - throw new BadRequestException('Phased clearance (Phase 1) applies to one-time 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); @@ -151,12 +192,20 @@ export class ContractClearanceService { const allApproved = await this.isClearanceFullyApproved(contract); const milestones = await this.workflowService.listMilestones(contractId); - const phase = this.workflowService.resolvePhase(contract, cycle, milestones); - const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - const boundary = await this.workflowService.isBoundaryComplete( + 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 nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + const documentFileKeys = new Set(documents.map((d) => d.fileKey)); + const workflowFiles = buildWorkflowFiles( + files, + contract.tradeDirection ?? 'IMPORT', + documentFileKeys, + ); return { contractId, @@ -187,6 +236,34 @@ export class ContractClearanceService { ? cycle.roAmendmentRequestedAt.toISOString() : null, bookingReady: boundary, + preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), + 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, }; } @@ -462,6 +539,12 @@ export class ContractClearanceService { } as never); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); + if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) { + await this.contractsRepository.updateCycle(cycle.id, { + preClearanceFinalizedAt: null, + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } } } else if (status === 'APPROVED') { await this.bumpToUnderReviewWhenFullyApproved(contractId); @@ -514,8 +597,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); @@ -533,6 +618,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); @@ -661,6 +764,24 @@ export class ContractClearanceService { // ── 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[], @@ -668,15 +789,17 @@ export class ContractClearanceService { ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); + await this.ensureDeclarationPrerequisites(contractId, contract); await this.workflowService.assertPriorComplete( contractId, contract.tradeDirection, - 'DECLARED', + 'UNDER_CUSTOMS_CLEARANCE', ); if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } + assertDeclarationFiles(files, contract.tradeDirection); for (const file of files) { await this.filesService.upsertByCode({ @@ -706,6 +829,7 @@ export class ContractClearanceService { contractId: string, dto: AdviseContractDutyDto, userId?: string, + attachment?: Express.Multer.File, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -730,6 +854,15 @@ export class ContractClearanceService { 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, { @@ -808,13 +941,40 @@ export class ContractClearanceService { const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { - currentPhase: ContractDocPhase.GlDjCollection, + 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, @@ -825,6 +985,14 @@ export class ContractClearanceService { 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'); 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 8bcce1250..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); 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 282cb7fa0..738f9d9de 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -43,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'; @@ -92,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) ─────────────── @@ -486,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, @@ -536,13 +543,39 @@ export class ContractsController { @Post(':id/clearance/duty') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) - @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount' }) + @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() dto: AdviseContractDutyDto, + @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, ) { - return this.clearanceService.adviseDuty(id, dto, resolveAuthUserId(user)); + 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') @@ -846,11 +879,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 468cf9d92..94f469112 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -18,6 +18,7 @@ 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'; @@ -87,6 +88,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractTransitionService, ContractClearanceService, ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, GlOperationsService, @@ -106,6 +108,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum 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 3f59e976f..45603ac76 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -547,6 +547,7 @@ export class ContractsRepository extends BaseRepository { | 'roHoldReason' | 'currentPhase' | 'status' + | 'preClearanceFinalizedAt' > >, ): Promise { 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 9d56bfe42..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 @@ -51,4 +51,8 @@ export class ContractClearanceCycle extends BaseEntity { @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/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts new file mode 100644 index 000000000..b48cf274a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -0,0 +1,61 @@ +import { BadRequestException } from '@nestjs/common'; +import { + catalogEntriesForTradeDirection, + type ClearanceWorkflowFile, +} from '@edr/types'; + +const IMPORT_DECLARATION_CODES = new Set(['im4', 'im5']); +const EXPORT_DECLARATION_CODES = new Set(['ex3', 'ex8']); + +/** Require at least one declaration file for the trade direction (IM4 or IM5, EX3 or EX8). */ +export function assertDeclarationFiles( + files: Express.Multer.File[], + tradeDirection: string, +): void { + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + const allowed = + tradeDirection === 'EXPORT' ? EXPORT_DECLARATION_CODES : IMPORT_DECLARATION_CODES; + const labels = tradeDirection === 'EXPORT' ? 'EX3 or EX8' : 'IM4 or IM5'; + + const uploaded = new Set(files.map((f) => f.fieldname?.toLowerCase())); + const hasValid = [...allowed].some((code) => uploaded.has(code)); + if (!hasValid) { + throw new BadRequestException(`Upload at least one declaration document (${labels}).`); + } +} + +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'; +} + +/** 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, + documentFileKeys: Set = new Set(), +): ClearanceWorkflowFile[] { + const fileByCode = new Map( + files.filter((f) => f.code).map((f) => [f.code as string, f]), + ); + const out: ClearanceWorkflowFile[] = []; + + for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { + if (documentFileKeys.has(entry.code)) continue; + const file = fileByCode.get(entry.code) ?? null; + if (!file) continue; + out.push({ + code: entry.code, + label: entry.label, + uploadedBy: entry.uploadedBy, + category: entry.category, + file: { id: file.id, name: file.name, url: file.url }, + }); + } + + return out; +} 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 1e73dabb0..67dabdf8c 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 @@ -13,6 +13,7 @@ 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'; @@ -549,7 +550,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, scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, @@ -624,7 +625,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, scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, 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/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 472eed26a..a008452e4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -4,7 +4,6 @@ import { Container, FileSignature, FileText, - Flag, LayoutDashboard, LayoutGrid, Network, @@ -15,14 +14,21 @@ 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"; @@ -37,11 +43,11 @@ 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 GlEthiopiaClearanceListPage from "./pages/contracts/GlEthiopiaClearanceListPage"; -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"; @@ -139,19 +145,17 @@ 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, + FREIGHT_PERMS.contracts.clearanceDjActions, + ], }, { - label: "GL Ethiopia Clearance", - href: "/dashboard/gl-ethiopia/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceEtActions, - }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, }, { label: "Train Schedules", @@ -481,7 +485,11 @@ const App = () => { /> } + element={ + + + + } /> {/* Contracts (Path A/B) */} @@ -509,11 +517,41 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> {/* GL (Path B) contract clearance review hub */} + } @@ -521,51 +559,21 @@ const App = () => { + } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + } /> + } /> + } /> + } /> {/* Path A ops queue out of scope for now → fold into the GL hub. */} { ); }; +/** Redirect legacy GL Ethiopia/Djibouti clearance URLs to the unified hub. */ +function LegacyGlClearanceRedirect() { + const { id } = useParams(); + if (id) { + return ; + } + return ; +} + export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx index e9a0b811c..ba93a790d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx @@ -4,17 +4,6 @@ import type { Freight } from "@edr/types"; const BRAND_GREEN = "var(--freight-brand, #0A6F4D)"; -const PHASE_LABELS: Record = { - CUSTOMER_INTAKE: "Customer docs", - GL_ET_REVIEW: "GL ET review", - GL_DJ_COLLECTION: "GL Djibouti", - GL_ET_OUTPUT: "Declaration", - CUSTOMER_DUTY: "Duty / tax", - GL_ET_POST_CLEARANCE: "ET clearance", - GL_DJ_LOADING: "Loading", - POST_TRANSIT: "Transit", -}; - const IMPORT_PHASES = [ "CUSTOMER_INTAKE", "GL_ET_REVIEW", @@ -24,6 +13,17 @@ const IMPORT_PHASES = [ "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", @@ -43,7 +43,7 @@ export function ClearancePhaseStepper({ tradeDirection, compact = false, }: { - clearance?: Freight.ContractClearanceView | null; + clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null; tradeDirection?: string; compact?: boolean; }) { 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 c5be46e45..bd72e809d 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,17 @@ 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 readOnly}. + */ + approvalsLocked?: 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 +100,8 @@ export function ContractClearanceReviewSection({ hideSummary, selfClear = false, readOnly = false, + phasedCustoms = false, + approvalsLocked = false, }: ContractClearanceReviewSectionProps) { const [queryNotes, setQueryNotes] = useState>({}); const [openQuery, setOpenQuery] = useState>({}); @@ -170,14 +183,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." + : approvalsLocked + ? "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 && !approvalsLocked && approvableKeys.length > 0 && ( - {!isApproved && ( + {!isApproved && !approvalsLocked && ( + + ) : ( + + )} + + + + + ); + } return ( @@ -56,47 +338,34 @@ export function PhasedClearanceActionPanel({ ) : null} - {next ? ( - - - {next.actor.replace("_", " ")} — {next.action} - - - ) : null} - - {showEt && canEt && isImport ? ( - - ) : null} - - {showEt && canEt && isImport ? ( - - ) : null} - - {showEt && canEt && isImport ? ( - - ) : null} - - {showDj && canDj && isImport ? ( - - ) : null} - - {showDj && canDj && !isImport ? ( + {showDj && canDj ? ( ) : null} - {showEt && canEt && !isImport ? ( - + {showEt && canEt ? ( + ) : null} - {showEt && canEt && !isImport ? ( - + {showEt && canEt ? ( + ) : null} - {clearance.bookingReady && bookingCreateHref && showEt && canEt ? ( + {(clearance.bookingReady || clearance.operationReady) && bookingCreateHref && showEt && canEt ? ( Pre-booking clearance is complete. Create the shipment booking for the customer. @@ -110,14 +379,47 @@ export function PhasedClearanceActionPanel({ ); } -function DeclarationCard({ - contractId, +function StepStatus({ + done, + pendingLabel, + doneLabel, +}: { + done: boolean; + pendingLabel: string; + doneLabel: string; +}) { + return ( + + + {done ? ( + }> + Done + + ) : ( + }> + Pending + + )} + + {done ? doneLabel : pendingLabel} + + + + ); +} + +function DeclarationStep({ + entityId, + isBooking, onChanged, exportMode = false, + replaceMode = false, }: { - contractId: string; + entityId: string; + isBooking: boolean; onChanged?: () => void; exportMode?: boolean; + replaceMode?: boolean; }) { const [files, setFiles] = useState>({}); const [loading, setLoading] = useState(false); @@ -129,232 +431,292 @@ function DeclarationCard({ ] : [ { key: "im4", label: "IM4" }, - { key: "im5", label: "IM5 (optional)" }, + { key: "im5", label: "IM5" }, ]; + const hasFile = Object.values(files).some(Boolean); + return ( - - - {fields.map((f) => ( - setFiles((prev) => ({ ...prev, [f.key]: file }))} - size="sm" - /> - ))} - - - + ); } -function DutyCard({ - contractId, +function DutyStep({ + entityId, + isBooking, clearance, onChanged, }: { - contractId: string; - clearance: Freight.ContractClearanceView; + entityId: string; + isBooking: boolean; + clearance: ClearanceViewLike; onChanged?: () => void; }) { const [dutyRequired, setDutyRequired] = useState(clearance.dutyRequired ?? true); - const [amount, setAmount] = useState(""); - const [currency, setCurrency] = useState("ETB"); - const [serial, setSerial] = useState(""); + const [amount, setAmount] = useState( + clearance.dutyAdvice?.amount ?? "", + ); + const [currency, setCurrency] = useState(clearance.dutyAdvice?.currency ?? "ETB"); + const [serial, setSerial] = useState(clearance.dutyAdvice?.declarationSerial ?? ""); + const [attachment, setAttachment] = useState(null); const [loading, setLoading] = useState(false); - const advised = clearance.milestones?.some( - (m) => m.milestoneCode === "DUTY_TAXES_ADVISED" && m.status === "COMPLETED", - ); - return ( - - - setDutyRequired(e.currentTarget.checked)} - /> - {dutyRequired ? ( - <> - - - setCurrency(v ?? "ETB")} size="sm" /> - - ) : null} - - - + toast.success(dutyRequired ? "Duty advised" : "Duty step skipped"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }} + > + Save duty settings + + ); } -function TransitPermitCard({ - contractId, +function TransitPermitStep({ + entityId, + isBooking, onChanged, }: { - contractId: string; + entityId: string; + isBooking: boolean; onChanged?: () => void; }) { const [file, setFile] = useState(null); const [loading, setLoading] = useState(false); return ( - - - - - - + toast.success("Transit permit uploaded"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }} + > + Upload transit permit + + ); } -function DeliveryOrderCard({ - contractId, +function FinalizePreClearanceStep({ + entityId, + isBooking, onChanged, }: { - contractId: string; + entityId: string; + isBooking: boolean; + onChanged?: () => void; +}) { + const [loading, setLoading] = useState(false); + + return ( + + + Confirm that Ethiopia-side pre-clearance is complete. This unlocks GL Djibouti to + upload the Delivery Order. + + + + ); +} + +function DeliveryOrderStep({ + entityId, + isBooking, + onChanged, +}: { + entityId: string; + isBooking: boolean; onChanged?: () => void; }) { const [file, setFile] = useState(null); const [loading, setLoading] = useState(false); return ( - - - - - - + toast.success("Delivery Order uploaded"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }} + > + Upload DO + + ); } function ReleaseOrderCard({ - contractId, + entityId, + isBooking, clearance, onChanged, }: { - contractId: string; - clearance: Freight.ContractClearanceView; + entityId: string; + isBooking: boolean; + clearance: ClearanceViewLike & { vesselDepartureDate?: string | null }; onChanged?: () => void; }) { const [file, setFile] = useState(null); @@ -365,12 +727,10 @@ function ReleaseOrderCard({ const [amendLoading, setAmendLoading] = useState(false); return ( - + + + Release Order + { setAmendLoading(true); try { - await contractsService.requestRoAmendment( - contractId, - "Port amendment requested — vessel window too short.", - ); + if (isBooking) { + await bookingsService.requestRoAmendment( + entityId, + "Port amendment requested — vessel window too short.", + ); + } else { + await contractsService.requestRoAmendment( + entityId, + "Port amendment requested — vessel window too short.", + ); + } toast.success("Amendment request recorded"); onChanged?.(); } catch (e) { @@ -433,38 +798,41 @@ function ReleaseOrderCard({ - + ); } function ExportReleaseCard({ - contractId, + entityId, + isBooking, clearance, onChanged, }: { - contractId: string; - clearance: Freight.ContractClearanceView; + entityId: string; + isBooking: boolean; + clearance: ClearanceViewLike; onChanged?: () => void; }) { const [loading, setLoading] = useState(false); - const done = clearance.bookingReady; + const done = clearance.bookingReady || clearance.operationReady; return ( - + + + Export release + - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index 23925e8ca..44ece975d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -26,6 +26,7 @@ import { } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; +import { clearanceWorkflowFileLabel } from "@edr/types"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; @@ -199,9 +200,10 @@ function formatBytes(bytes?: number | null): string { /** Human label for a file's `code` (e.g. "business_license" → "Business license"). */ function codeLabel(code?: string | null): string | null { if (!code) return null; - return code - .replace(/[_-]+/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()); + return ( + clearanceWorkflowFileLabel(code) ?? + code.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) + ); } export interface ContractDocumentsCardProps { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index ecbdcc1e6..e1ff69412 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -34,6 +34,7 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { formatRouteLabel } from "@/services/routes.service"; import { useToast } from "@/hooks/use-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { BookingDetail } from "@/types/booking"; @@ -138,7 +139,9 @@ export function AllocateBookingWizard({ const schedulesQuery = useQuery( api.trainScheduling.scheduleList.queryOptions({ input: {} }), ); - const routesQuery = useQuery(api.routes.list.queryOptions()); + const routesQuery = useQuery( + api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }), + ); const locomotivesQuery = useQuery( api.trainScheduling.availableLocomotives.queryOptions({ input: { @@ -244,7 +247,7 @@ export function AllocateBookingWizard({ }, [containerUnits, containerSlots, savedPlacementsFromSchedule]); const activeRoutes = useMemo( - () => (routesQuery.data ?? []).filter((r) => r.isActive), + () => routesQuery.data ?? [], [routesQuery.data], ); @@ -522,7 +525,7 @@ export function AllocateBookingWizard({