From 612df8daff086b003af110b699bf02a9022f7c40 Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 1 Jul 2026 10:20:16 +0300 Subject: [PATCH] Enhance clearance milestone management and introduce new contract actions - Added new milestones for 'Transit Permit Uploaded' and 'Export Transport Document Issued' in the clearance milestone catalog. - Implemented methods in ClearanceMilestoneService to skip milestones and complete them with metadata. - Updated ContractBookingService to check boundary conditions before booking creation. - Introduced new endpoints in ContractsController for uploading customs declarations, advising duty, and handling various document uploads. - Enhanced the UI to support new clearance actions and display relevant components based on milestone statuses. --- .../1829000000000-PhasedClearanceCycleMeta.ts | 41 ++ .../1829000000001-SeedRoVesselMinDays.ts | 46 ++ .../contracts/clearance-milestone.catalog.ts | 10 + .../contracts/clearance-milestone.service.ts | 52 ++ .../clearance-workflow.service.spec.ts | 284 ++++++++++ .../contracts/clearance-workflow.service.ts | 333 ++++++++++++ .../contracts/contract-booking.service.ts | 12 +- .../contracts/contract-clearance.service.ts | 484 +++++++++++++++++- .../modules/contracts/contracts.controller.ts | 136 ++++- .../src/modules/contracts/contracts.module.ts | 7 +- .../modules/contracts/contracts.repository.ts | 29 +- .../contracts/dto/phased-clearance.dto.ts | 37 ++ .../contract-clearance-cycle.entity.ts | 17 + .../contracts/gl-operations.service.ts | 44 ++ .../train-scheduling/booking-batch.service.ts | 13 + .../train-scheduling.module.ts | 2 + .../src/seed/freight-permissions.registry.ts | 9 + apps/edr-freight-web/backoffice/src/App.tsx | 57 +++ .../contracts/ClearancePhaseStepper.tsx | 112 ++++ .../contracts/PhasedClearanceActionPanel.tsx | 481 +++++++++++++++++ .../contracts/gl-actions/GlActionsPanel.tsx | 13 + .../gl-actions/TransportDocumentCard.tsx | 49 ++ .../backoffice/src/constants/URLS.ts | 17 + .../src/hooks/contracts/useContracts.ts | 16 + .../backoffice/src/lib/permissions.ts | 4 +- .../contracts/ContractClearanceDetailPage.tsx | 132 +++-- .../pages/contracts/GlClearanceDetailPage.tsx | 126 +++++ .../contracts/GlDjiboutiClearanceListPage.tsx | 63 +++ .../contracts/GlEthiopiaClearanceListPage.tsx | 63 +++ .../src/services/contracts.service.ts | 99 ++++ .../portal/src/constants/URLS.ts | 1 + .../pages/contracts/ClearancePhaseStepper.tsx | 112 ++++ .../ContractClearanceWorkflowBanner.tsx | 134 +++++ .../pages/contracts/ContractDetailPage.tsx | 5 + .../portal/src/services/contracts.service.ts | 12 + packages/types/src/freight/contracts.ts | 23 + 36 files changed, 3018 insertions(+), 57 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts create mode 100644 apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlEthiopiaClearanceListPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx diff --git a/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts new file mode 100644 index 000000000..75288e94c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface { + name = 'PhasedClearanceCycleMeta1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts new file mode 100644 index 000000000..7165ea7a8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Admin-configurable minimum days between today and export RO vessel departure. */ +export class SeedRoVesselMinDays1829000000001 implements MigrationInterface { + name = 'SeedRoVesselMinDays1829000000001'; + private readonly code = 'ro_vessel_min_days'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '2', label: '2 days' }, + { value: '3', label: '3 days' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'RO vessel minimum lead time (days)', + 'Minimum days between today and the vessel departure date on an export Release Order.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 3389933a7..ce6f7bea4 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -22,6 +22,11 @@ const IMPORT_DEFS: Record> = { DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true }, + TRANSIT_PERMIT_UPLOADED: { + label: 'Transit Permit Uploaded', + ownerRegion: 'ET', + triggeredByDoc: true, + }, DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true }, WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true }, @@ -52,6 +57,11 @@ const EXPORT_DEFS: Record> = { FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true }, WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, + EXPORT_TRANSPORT_ISSUED: { + label: 'Export Transport Document Issued', + ownerRegion: 'ET', + triggeredByDoc: true, + }, CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false }, READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false }, LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 947fc6979..0edae5ea0 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 @@ -187,6 +187,58 @@ export class ClearanceMilestoneService { return this.repo.save(milestone); } + /** Skip optional milestones (e.g. duty when not required). */ + async skipForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + /** Complete a contract milestone with structured metadata (duty advice, etc.). */ + async completeWithMetadataForContract( + contractId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') { + throw new BadRequestException(`Milestone ${code} is already completed.`); + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + async adviseDutyForContract( + contractId: string, + input: { amount: number; currency: string; declarationSerial?: string }, + userId?: string, + ): Promise { + return this.completeWithMetadataForContract( + contractId, + 'DUTY_TAXES_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency, + declarationSerial: input.declarationSerial, + }, + userId, + ); + } + /** Complete a doc-triggered milestone when its document is uploaded/approved. */ async completeByDocTrigger( scope: { bookingId?: string; contractId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts new file mode 100644 index 000000000..c5bd1e1d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -0,0 +1,284 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import type { Contract } from './entities/contract.entity'; +import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; + +function ms( + code: string, + status: 'PENDING' | 'COMPLETED' | 'SKIPPED', + ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET', +): ClearanceMilestone { + return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone; +} + +function importThroughDeclaration(): ClearanceMilestone[] { + return [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'), + ms('DECLARED', 'PENDING', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; +} + +function makeService(milestones: ClearanceMilestone[]) { + const contractsRepository = { + currentCycle: jest.fn(), + update: jest.fn(), + setCycleStatus: jest.fn(), + updateCycle: jest.fn(), + }; + const milestoneService = { + listForContract: jest.fn().mockResolvedValue(milestones), + skipForContract: jest.fn(), + completeForContract: jest.fn(), + completeWithMetadataForContract: jest.fn(), + }; + const service = new ClearanceWorkflowService( + contractsRepository as never, + milestoneService as never, + ); + return { service, milestoneService, contractsRepository }; +} + +const importContract = { + id: 'c-import', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +const exportContract = { + id: 'c-export', + tradeDirection: 'EXPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +describe('ClearanceWorkflowService', () => { + describe('boundaryMilestone', () => { + it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => { + const { service } = makeService([]); + expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED'); + expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED'); + }); + }); + + describe('assertPriorComplete', () => { + it('rejects when a prior milestone is still pending', async () => { + const milestones = importThroughDeclaration().map((m) => + m.milestoneCode === 'DOCUMENTS_APPROVED' + ? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET') + : m, + ); + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows proceeding when prior milestones are completed or skipped', async () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'), + ).resolves.toBeUndefined(); + }); + }); + + describe('isBoundaryComplete', () => { + it('returns true only when boundary milestone is completed', async () => { + const done = [ + ...importThroughDeclaration().slice(0, -1), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service: doneSvc } = makeService(done); + await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true); + + const pending = importThroughDeclaration(); + const { service: pendingSvc } = makeService(pending); + await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false); + }); + }); + + describe('onDutySkipped', () => { + it('skips duty milestones on the contract', async () => { + const { service, milestoneService } = makeService([]); + await service.onDutySkipped('c-import'); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAXES_ADVISED', + ); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAX_PAID', + ); + }); + }); + + describe('computeNextAction — import happy path', () => { + it('prompts customer to upload docs first', () => { + const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]); + const next = service.computeNextAction(importContract, null, [ + ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'), + ]); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED'); + }); + + it('prompts ET review after customer docs', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, null, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/Review/i); + }); + + it('prompts duty toggle when declaration done and duty unset', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: null } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/duty/i); + }); + + it('prompts customer duty slip when duty required and advised', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('DUTY_TAX_PAID'); + }); + + it('skips duty path when duty not required', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); + }); + + it('prompts 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 } as ContractClearanceCycle; + const { service } = makeService(milestones); + const djNext = service.computeNextAction(importContract, cycle, milestones); + expect(djNext?.actor).toBe('GL_DJ'); + + const booked = milestones.map((m) => + m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m, + ); + const etNext = service.computeNextAction(importContract, cycle, booked); + expect(etNext?.actor).toBe('GL_ET'); + expect(etNext?.action).toMatch(/booking/i); + }); + }); + + describe('computeNextAction — export RO hold', () => { + it('surfaces DJ action when RO is on hold', () => { + const milestones = [ + ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'), + ]; + const cycle = { + roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).', + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(exportContract, cycle, milestones); + expect(next?.actor).toBe('GL_DJ'); + expect(next?.blockedReason).toMatch(/minimum lead time/i); + }); + }); + + describe('inferPhase', () => { + it('places import contract in customer duty phase when duty outstanding', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const phase = service.inferPhase(importContract, cycle, milestones); + expect(phase).toBe(ContractDocPhase.CustomerDuty); + }); + }); + + describe('queue helpers', () => { + it('returns first pending ET-owned milestone code', () => { + const { service } = makeService([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]); + expect(service.etPendingMilestoneCodes([])).toBeNull(); + expect( + service.etPendingMilestoneCodes([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]), + ).toBe('DOCUMENTS_APPROVED'); + }); + + it('returns first pending DJ-owned milestone code', () => { + const { service } = makeService([]); + expect( + service.djPendingMilestoneCodes([ + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]), + ).toBe('DO_COLLECTED'); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts new file mode 100644 index 000000000..44bb6c8a7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -0,0 +1,333 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ContractsRepository } from './contracts.repository'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { MilestoneMetadata } from './entities/clearance-milestone.entity'; +import { splitMilestones } from './clearance-milestone.catalog'; +import { Contract } from './entities/contract.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; + +export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS'; + +export interface ClearanceNextAction { + actor: ClearanceActorRole; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; +} + +const IMPORT_BOUNDARY = 'DO_COLLECTED'; +const EXPORT_BOUNDARY = 'EXPORT_RELEASED'; + +const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED'; +const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED'; + +@Injectable() +export class ClearanceWorkflowService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly milestoneService: ClearanceMilestoneService, + ) {} + + boundaryMilestone(tradeDirection: string): string { + return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY; + } + + async listMilestones(contractId: string): Promise { + return this.milestoneService.listForContract(contractId); + } + + async isBoundaryComplete(contractId: string, tradeDirection: string): Promise { + const code = this.boundaryMilestone(tradeDirection); + const milestones = await this.listMilestones(contractId); + const m = milestones.find((x) => x.milestoneCode === code); + return m?.status === 'COMPLETED'; + } + + async assertBoundaryComplete(contract: Contract): Promise { + const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection); + if (!ok) { + throw new BadRequestException( + `Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`, + ); + } + } + + async assertPriorComplete( + contractId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + 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; + if (m.status === 'SKIPPED') continue; + if (m.status !== 'COMPLETED') { + throw new BadRequestException( + `Complete "${preBooking[i]!.label}" before proceeding.`, + ); + } + } + } + + async skipMilestones(contractId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForContract(contractId, code); + } + } + + async completeMilestone( + contractId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForContract( + contractId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForContract(contractId, code, userId); + } + + async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestone(contractId, uploaded); + await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onAllDocsApproved(contractId: string): Promise { + await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED'); + } + + async onDeclarationUploaded(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestone(contractId, 'DECLARED', userId); + } + + async onDutySkipped(contractId: string): Promise { + await this.skipMilestones(contractId, ['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 markReadyForBooking(contractId: string): Promise { + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_READY_FOR_BOOKING', + clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { + clearanceReadyAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + } + + resolvePhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + if (cycle?.currentPhase) { + return cycle.currentPhase as ContractDocPhase; + } + return this.inferPhase(contract, cycle, milestones); + } + + inferPhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + 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' + ? 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 (!isDone('RELEASE_ORDER_SECURED')) { + if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection; + return ContractDocPhase.GlDjCollection; + } + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; + return ContractDocPhase.GlEtPostClearance; + } + + // Import + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { + return ContractDocPhase.CustomerDuty; + } + if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance; + if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection; + return ContractDocPhase.GlEtPostClearance; + } + + computeNextAction( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + if (cycle?.roHoldReason) { + return { + actor: 'GL_DJ', + action: 'Re-upload Release Order or request port amendment', + milestoneCode: 'RELEASE_ORDER_SECURED', + blockedReason: cycle.roHoldReason, + }; + } + + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const pending = (code: string) => { + const m = byCode.get(code); + return m && m.status === 'PENDING'; + }; + const isDone = (code: string) => { + const m = byCode.get(code); + return m?.status === 'COMPLETED' || m?.status === 'SKIPPED'; + }; + + const docCode = + contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + + if (pending(docCode) || !isDone(docCode)) { + return { + actor: 'CUSTOMER', + action: 'Upload clearance documents', + milestoneCode: docCode, + }; + } + + if (!isDone('DOCUMENTS_APPROVED')) { + return { + actor: 'GL_ET', + action: 'Review and approve customer documents', + milestoneCode: 'DOCUMENTS_APPROVED', + }; + } + + if (contract.tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration (EX3/EX8)', + milestoneCode: 'DECLARED', + }; + } + if (!isDone(EXPORT_BOUNDARY)) { + return { + actor: 'GL_ET', + action: 'Confirm export release', + milestoneCode: EXPORT_BOUNDARY, + }; + } + return { + actor: 'GL_ET', + action: 'Create shipment booking', + milestoneCode: EXPORT_BOUNDARY, + }; + } + + // Import + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration (IM4/IM5)', + milestoneCode: 'DECLARED', + }; + } + + if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) { + return { + actor: 'GL_ET', + action: 'Set whether duty/tax applies', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + + if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) { + if (!isDone('DUTY_TAXES_ADVISED')) { + return { + actor: 'GL_ET', + action: 'Advise duty and tax amount', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + return { + actor: 'CUSTOMER', + action: 'Upload duty/tax payment slip', + milestoneCode: 'DUTY_TAX_PAID', + }; + } + + if (!isDone('TRANSIT_PERMIT_UPLOADED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit screenshot', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!isDone(IMPORT_BOUNDARY)) { + return { + actor: 'GL_DJ', + action: 'Upload Delivery Order', + milestoneCode: IMPORT_BOUNDARY, + }; + } + + return { + actor: 'GL_ET', + action: 'Create shipment booking', + 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/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index b077930b7..f8ed894c8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ @@ -56,6 +57,7 @@ export class ContractBookingService { private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, + private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, ) {} @@ -246,10 +248,14 @@ export class ContractBookingService { } return 'GL_ET'; } - // ONE_TIME customs — UNCHANGED: requires the finalized contract cycle. - if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { + // ONE_TIME customs — pre-booking boundary milestone must be complete. + const boundaryOk = await this.workflowService.isBoundaryComplete( + contract.id, + contract.tradeDirection, + ); + if (!boundaryOk) { throw new BadRequestException( - 'Contract clearance is not ready for booking yet.', + 'Pre-booking clearance is not complete — booking cannot be created yet.', ); } return 'GL_ET'; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index a57ca275b..972212e6d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,13 +1,20 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; import { contractClearanceCodes } from './contract-clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; 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'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface ContractClearanceDocument { fileKey: string; @@ -34,6 +41,28 @@ export interface ContractClearanceView { outputCode: string | null; documents: ContractClearanceDocument[]; allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + bookingReady?: boolean; } @Injectable() @@ -43,8 +72,20 @@ export class ContractClearanceService { private readonly contractsService: ContractsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, ) {} + private assertPhasedCustoms(contract: Contract): void { + if (!contract.customsClearingEnabled) { + throw new BadRequestException('Phased clearance applies only to customs contracts.'); + } + if (contract.contractKind !== 'ONE_TIME') { + throw new BadRequestException('Phased clearance (Phase 1) applies to one-time contracts.'); + } + } + /** The pre-booking clearance document grid for a contract (Path B). */ async getClearanceView(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -109,6 +150,13 @@ 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( + contractId, + contract.tradeDirection, + ); return { contractId, @@ -120,6 +168,25 @@ export class ContractClearanceService { outputCode, documents, allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: cycle?.dutyRequired ?? null, + roHold: Boolean(cycle?.roHoldReason), + roHoldReason: cycle?.roHoldReason ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt + ? cycle.roAmendmentRequestedAt.toISOString() + : null, + bookingReady: boundary, }; } @@ -262,8 +329,15 @@ export class ContractClearanceService { clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); if (cycle) { - await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', { + currentPhase: ContractDocPhase.GlEtReview, + }); } + + if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { + await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); + } + return this.contractsService.findById(contractId); } @@ -391,6 +465,23 @@ export class ContractClearanceService { } } else if (status === 'APPROVED') { await this.bumpToUnderReviewWhenFullyApproved(contractId); + const refreshed = await this.contractsService.findById(contractId); + if ( + refreshed.customsClearingEnabled && + refreshed.contractKind === 'ONE_TIME' && + (await this.isClearanceFullyApproved(refreshed)) + ) { + await this.workflowService.onAllDocsApproved(contractId); + const c = await this.contractsRepository.currentCycle(contractId); + if (c) { + await this.contractsRepository.updateCycle(c.id, { + currentPhase: + refreshed.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + } } return this.contractsService.findById(contractId); @@ -567,4 +658,395 @@ export class ContractClearanceService { sortOrder: filter.sortOrder ?? 'DESC', }); } + + // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── + + async uploadDeclaration( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + await this.workflowService.assertPriorComplete( + contractId, + contract.tradeDirection, + 'DECLARED', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + for (const file of files) { + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: file.fieldname, + file, + }); + } + + await this.workflowService.onDeclarationUploaded(contractId, userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + }); + } + + return this.contractsService.findById(contractId); + } + + async adviseDuty( + contractId: string, + dto: AdviseContractDutyDto, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED'); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + dutyRequired: dto.dutyRequired, + currentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + }); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkipped(contractId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + await this.milestoneService.adviseDutyForContract( + contractId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.contractsService.findById(contractId); + } + + async uploadDutySlip( + contractId: string, + file: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID'); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async uploadTransitPermit( + contractId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (!file) throw new BadRequestException('No transit permit uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'transit_permitted', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlDjCollection, + }); + } + + return this.contractsService.findById(contractId); + } + + async uploadDeliveryOrder( + contractId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'delivery_order', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + + return this.contractsService.findById(contractId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + contractId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'release_order', + file, + }); + + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + }); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason }; + } + + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: null, + currentPhase: ContractDocPhase.GlEtOutput, + }); + await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + + return { contract: await this.contractsService.findById(contractId), hold: false }; + } + + async requestRoAmendment( + contractId: string, + note?: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.contractsRepository.updateCycle(cycle.id, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + + if (userId) { + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CHANGES_REQUESTED', + userId, + 'GL_DJ', + ); + } + + return this.contractsService.findById(contractId); + } + + async confirmExportRelease(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED'); + + await this.workflowService.onExportReleased(contractId, userId); + return this.contractsService.findById(contractId); + } + + /** GL ET queue: customs ONE_TIME contracts with a pending ET-owned milestone. */ + async etQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const milestones = await this.workflowService.listMilestones(c.id); + const pending = this.workflowService.etPendingMilestoneCodes(milestones); + const next = this.workflowService.computeNextAction( + c, + await this.contractsRepository.currentCycle(c.id), + milestones, + ); + if (pending || next?.actor === 'GL_ET') filtered.push(c); + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } + + /** GL DJ queue: customs ONE_TIME contracts with a pending DJ-owned milestone or RO hold. */ + async djQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const cycle = await this.contractsRepository.currentCycle(c.id); + const milestones = await this.workflowService.listMilestones(c.id); + const pending = this.workflowService.djPendingMilestoneCodes(milestones); + const next = this.workflowService.computeNextAction(c, cycle, milestones); + if (cycle?.roHoldReason || pending || next?.actor === 'GL_DJ') filtered.push(c); + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 100183e64..282cb7fa0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -12,11 +12,12 @@ import { Res, UnauthorizedException, UploadedFiles, + UploadedFile, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { ApiBearerAuth, @@ -72,6 +73,10 @@ import { CompleteMilestoneDto, ReportIncidentDto, } from './dto/gl-operations.dto'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from './dto/phased-clearance.dto'; @ApiTags('contracts') @Controller('contracts') @@ -511,11 +516,126 @@ export class ContractsController { @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) - @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' }) + @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' }) finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalize(id); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration (IM4/IM5 or EX3/EX8)' }) + uploadDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount' }) + adviseContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AdviseContractDutyDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.adviseDuty(id, dto, resolveAuthUserId(user)); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) + uploadContractDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.clearanceService.uploadDutySlip(id, file); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads transit permit screenshot (import)' }) + uploadTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadTransitPermit(id, file, resolveAuthUserId(user)); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + uploadDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + uploadReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' }) + requestRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user)); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET confirms export release after declaration' }) + confirmExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); + } + + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL Ethiopia phased clearance queue' }) + etClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.etQueue(filter); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL Djibouti phased clearance queue' }) + djClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.djQueue(filter); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -696,6 +816,18 @@ export class ContractsController { }); } + @Post('bookings/:bookingId/transport-document') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads export transport document after wagon allocation' }) + uploadTransportDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadTransportDocument(bookingId, file); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index c595dbf5f..468cf9d92 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; @@ -18,6 +18,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 { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -71,7 +72,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). - BookingsModule, + forwardRef(() => BookingsModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -85,6 +86,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, ContractBookingService, ClearanceMilestoneService, GlOperationsService, @@ -103,6 +105,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, ContractBookingService, ClearanceMilestoneService, ], diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 354f02e8c..3f59e976f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository { cycleId: string, status: string, fields: Partial< - Pick + Pick< + ContractClearanceCycle, + | 'bookingId' + | 'clearanceReadyAt' + | 'completedAt' + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + > > = {}, ): Promise { await this.dataSource @@ -526,6 +536,23 @@ export class ContractsRepository extends BaseRepository { .update(cycleId, { status, ...fields } as never); } + async updateCycle( + cycleId: string, + fields: Partial< + Pick< + ContractClearanceCycle, + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + | 'status' + > + >, + ): Promise { + await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never); + } + /** Link the GL-created booking to a clearance cycle. */ async linkBooking(cycleId: string, bookingId: string): Promise { await this.dataSource diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts new file mode 100644 index 000000000..f48653822 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class AdviseContractDutyDto { + @ApiProperty({ description: 'Whether the customer must pay duty/tax' }) + @IsBoolean() + dutyRequired!: boolean; + + @ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Declaration / payment reference code' }) + @IsOptional() + @IsString() + declarationSerial?: string; +} + +export class ReleaseOrderDto { + @ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' }) + @IsString() + vesselDepartureDate!: string; +} + +export class RoAmendmentDto { + @ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index e29985f2b..9d56bfe42 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -34,4 +34,21 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) completedAt?: Date | null; + + /** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */ + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + /** Export RO vessel departure date (Path B export). */ + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true }) + currentPhase?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index e8f53f4c6..41d1db61c 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -21,6 +21,7 @@ const DOC_CODE_TO_MILESTONE: Record = { import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET + export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation }; /** @@ -158,4 +159,47 @@ export class GlOperationsService { } return { uploaded: files.length, completedMilestones }; } + + /** + * GL ET uploads export transport document after wagon allocation (export ONE_TIME). + */ + async uploadTransportDocument( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Transport document upload applies to export shipments only.'); + } + + const milestones = await this.milestoneService.listForBooking(bookingId); + const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonDone = + wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED'; + if (!wagonDone) { + throw new BadRequestException( + 'Wagon must be allocated before the transport document can be uploaded.', + ); + } + + if (!file) throw new BadRequestException('No transport document uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'export_transport_document', + file, + }); + + if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } + + await this.milestoneService.completeByDocTrigger( + { bookingId }, + 'EXPORT_TRANSPORT_ISSUED', + ); + + return { uploaded: true, milestoneCompleted: true }; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index f31d8561d..1e73dabb0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -4,6 +4,7 @@ import { Logger, NotFoundException, OnModuleInit, + Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { Cron, SchedulerRegistry } from '@nestjs/schedule'; @@ -34,6 +35,7 @@ import { wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -179,6 +181,7 @@ export class BookingBatchService implements OnModuleInit { private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} /** On boot, reconcile OPEN route-days and re-arm settle timers. */ @@ -1002,6 +1005,16 @@ export class BookingBatchService implements OnModuleInit { }); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); + void this.markWagonAllocatedMilestone(booking.id); + } + + private async markWagonAllocatedMilestone(bookingId: string): Promise { + if (!this.milestoneService) return; + try { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } catch { + // Booking may have no milestone rows (non-contract path). + } } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 9b8efd9e0..098bfa14f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -25,6 +25,7 @@ import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; import { NotificationsModule } from '../notifications/notifications.module'; +import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ @@ -49,6 +50,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; TrainSchedulesModule, forwardRef(() => WarehousesModule), RuleEngineModule, + forwardRef(() => ContractsModule), ], controllers: [TrainSchedulingController], providers: [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ba0d6da19..56ca98626 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -80,6 +80,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'), + perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), + perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), + perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -149,6 +152,9 @@ export const FREIGHT_PERMS = { finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', createBooking: 'edr_freight_app:contracts:create_booking', opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review', + clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', + clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', + clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -237,6 +243,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, @@ -247,6 +255,7 @@ export const ROLE_PERMISSION_PRESETS = { // damage reports. Read-only on the contract; no booking creation. glDjibouti: [ FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6526fb6cb..472eed26a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -4,6 +4,7 @@ import { Container, FileSignature, FileText, + Flag, LayoutDashboard, LayoutGrid, Network, @@ -14,6 +15,7 @@ import { Send, Settings, ShieldCheck, + Ship, SlidersHorizontal, Train, Truck, @@ -35,6 +37,9 @@ 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 GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; @@ -136,6 +141,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.clearanceReview, }, + { + 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: "Train Schedules", href: "/dashboard/operations/train-scheduling-v2", @@ -509,6 +526,46 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Path A ops queue out of scope for now → fold into the GL hub. */} = { + 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", + "GL_ET_OUTPUT", + "CUSTOMER_DUTY", + "GL_ET_POST_CLEARANCE", + "GL_DJ_COLLECTION", +] as const; + +const EXPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_DJ_COLLECTION", + "GL_ET_OUTPUT", + "GL_ET_POST_CLEARANCE", +] as const; + +function phaseIndex(phases: readonly string[], current?: string | null): number { + if (!current) return 0; + const idx = phases.indexOf(current); + return idx >= 0 ? idx : 0; +} + +export function ClearancePhaseStepper({ + clearance, + tradeDirection, + compact = false, +}: { + clearance?: Freight.ContractClearanceView | null; + tradeDirection?: string; + compact?: boolean; +}) { + const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES; + const current = clearance?.phase ?? phases[0]; + const activeIdx = phaseIndex(phases, current); + + return ( + + {phases.map((phase, index) => { + const isComplete = index < activeIdx; + const isActive = index === activeIdx; + const isLast = index === phases.length - 1; + + return ( + + + + + {isComplete ? : null} + + + {PHASE_LABELS[phase] ?? phase} + + + {!isLast && ( + + )} + + + ); + })} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx new file mode 100644 index 000000000..d265d3eea --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -0,0 +1,481 @@ +import { useState } from "react"; +import { + Alert, + Button, + FileInput, + Group, + NumberInput, + Select, + Stack, + Switch, + Text, + TextInput, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { AlertTriangle, FileText, Receipt, Ship, Upload } from "lucide-react"; +import type { Freight } from "@edr/types"; +import toast from "react-hot-toast"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { ActionShell } from "@/components/contracts/gl-actions/ActionShell"; +import { contractsService } from "@/services/contracts.service"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; + +type RoleMode = "ET" | "DJ" | "ALL"; + +export function PhasedClearanceActionPanel({ + contractId, + clearance, + tradeDirection, + roleMode = "ALL", + onChanged, + bookingCreateHref, +}: { + contractId: string; + clearance: Freight.ContractClearanceView; + tradeDirection: string; + roleMode?: RoleMode; + onChanged?: () => void; + bookingCreateHref?: string; +}) { + const { user } = useAuth(); + const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions); + const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); + const showEt = roleMode === "ET" || roleMode === "ALL"; + const showDj = roleMode === "DJ" || roleMode === "ALL"; + + const next = clearance.nextAction; + const isImport = tradeDirection === "IMPORT"; + + return ( + + {clearance.roHold && clearance.roHoldReason ? ( + } title="Release Order on hold"> + {clearance.roHoldReason} + + ) : 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 ? ( + + ) : null} + + {showEt && canEt && !isImport ? ( + + ) : null} + + {showEt && canEt && !isImport ? ( + + ) : null} + + {clearance.bookingReady && bookingCreateHref && showEt && canEt ? ( + + + Pre-booking clearance is complete. Create the shipment booking for the customer. + + + + ) : null} + + ); +} + +function DeclarationCard({ + contractId, + onChanged, + exportMode = false, +}: { + contractId: string; + onChanged?: () => void; + exportMode?: boolean; +}) { + const [files, setFiles] = useState>({}); + const [loading, setLoading] = useState(false); + + const fields = exportMode + ? [ + { key: "ex3", label: "EX3" }, + { key: "ex8", label: "EX8" }, + ] + : [ + { key: "im4", label: "IM4" }, + { key: "im5", label: "IM5 (optional)" }, + ]; + + return ( + + + {fields.map((f) => ( + setFiles((prev) => ({ ...prev, [f.key]: file }))} + size="sm" + /> + ))} + + + + ); +} + +function DutyCard({ + contractId, + clearance, + onChanged, +}: { + contractId: string; + clearance: Freight.ContractClearanceView; + onChanged?: () => void; +}) { + const [dutyRequired, setDutyRequired] = useState(clearance.dutyRequired ?? true); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [serial, setSerial] = useState(""); + 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 ? ( + <> + + +