From b17a72c28010c99e814e0ec9d1b8ce489ce7202c Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 29 Jul 2026 05:38:35 +0000 Subject: [PATCH] =?UTF-8?q?New=20Transit=20Agents=20admin=20table=20(name,?= =?UTF-8?q?=20valid-from/to,=20active/suspended,=20validity=20badge)=20?= =?UTF-8?q?=E2=80=94=20DJ=20assign=20step=20now=20picks=20from=20it,=20act?= =?UTF-8?q?ive+valid=20only.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edr-freight-api/src/app.module.ts | 2 + ...30000000000-AddGlExchangeDocumentFields.ts | 42 ++ .../3040000000000-AddTransitAgents.ts | 29 ++ .../booking-lifecycle-notifier.service.ts | 44 +- .../modules/bookings/bookings.controller.ts | 49 +- .../entities/booking-review-note.entity.ts | 6 +- .../booking-clearance.service.spec.ts | 6 + .../contracts/booking-clearance.service.ts | 218 ++++++-- .../contracts/booking-duty-dispute.spec.ts | 162 ------ .../contracts/clearance-milestone.catalog.ts | 2 + .../contracts/clearance-workflow.service.ts | 16 +- .../contracts/contract-clearance.service.ts | 44 +- .../contracts/contract-duty-dispute.spec.ts | 1 + .../modules/contracts/contracts.controller.ts | 20 +- .../src/modules/contracts/contracts.module.ts | 8 +- .../entities/clearance-milestone.entity.ts | 3 + .../contracts/final-invoice-approval.spec.ts | 102 ++++ .../contracts/gl-exchange.controller.ts | 133 +++++ .../modules/contracts/gl-exchange.service.ts | 198 ++++++++ .../contracts/gl-operations.service.ts | 128 ++++- .../contracts/phased-clearance.util.ts | 64 +++ .../contracts/transit-assignee.spec.ts | 24 +- .../src/modules/files/entities/file.entity.ts | 25 + .../src/modules/files/files.service.ts | 23 + .../dto/create-transit-agent.dto.ts | 31 ++ .../dto/update-transit-agent.dto.ts | 5 + .../entities/transit-agent.entity.ts | 24 + .../transit-agents.controller.ts | 87 ++++ .../transit-agents/transit-agents.module.ts | 15 + .../transit-agents.repository.ts | 28 + .../transit-agents/transit-agents.service.ts | 138 +++++ .../src/seed/freight-permissions.registry.ts | 2 + .../components/contracts/ClearanceOpsTabs.tsx | 30 +- .../contracts/ClearanceWorkflowFilesPanel.tsx | 2 + .../contracts/ExportClearanceStepper.tsx | 146 ++++-- .../components/contracts/GlExchangePanel.tsx | 479 ++++++++++++++++++ .../contracts/PhasedClearanceActionPanel.tsx | 412 ++++++++++++--- .../contracts/TransitAssigneePanel.tsx | 39 +- .../ruleEngine/ruleEngineFormat.tsx | 13 + .../compositionEditor/TrainConsistView.tsx | 2 +- .../backoffice/src/constants/URLS.ts | 13 + .../bookings/DocumentClearanceDetailPage.tsx | 1 + .../contracts/ContractClearanceDetailPage.tsx | 1 + .../pages/contracts/GlClearanceDetailPage.tsx | 9 + .../src/pages/ruleEngine/config/resources.ts | 34 ++ .../src/services/bookings.service.ts | 22 +- .../src/services/contracts.service.ts | 6 +- .../src/services/glExchange.service.ts | 60 +++ .../services/ruleEngine/ruleEngine.service.ts | 1 + .../src/services/transit-agents.service.ts | 20 + .../backoffice/src/types/rule-engine/index.ts | 3 +- .../portal/src/constants/URLS.ts | 2 + .../BookingClearanceWorkflowBanner.tsx | 208 ++++++-- .../components/DocumentsTab.tsx | 25 + .../portal/src/services/bookings.service.ts | 16 +- .../portal/src/services/contracts.service.ts | 8 + .../src/freight/clearance-files.catalog.ts | 13 + packages/types/src/freight/contracts.ts | 55 +- packages/types/src/freight/index.ts | 55 +- 59 files changed, 2903 insertions(+), 451 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts create mode 100644 apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts delete mode 100644 apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts create mode 100644 apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/glExchange.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/transit-agents.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 26afdc2d0..3e3e29980 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -30,6 +30,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; // import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; +import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -160,6 +161,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ConsignmentsModule, LocomotivesModule, TruckTypesModule, + TransitAgentsModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, diff --git a/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts b/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts new file mode 100644 index 000000000..2958f5406 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary + * `freight.files` rows (resource `gl_exchange`), so they only need the metadata + * a free-form upload has and a catalog-driven one does not: the uploader's own + * title, who uploaded it (the only user allowed to change it afterwards) and + * whether the customer may see it in the portal. + */ +export class AddGlExchangeDocumentFields3030000000000 + implements MigrationInterface +{ + name = 'AddGlExchangeDocumentFields3030000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS title varchar(300), + ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid, + ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`, + ); + // Every read of a thread is "all files of one resource" — index the pair. + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_files_resource_lookup + ON freight.files (resource, resource_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`, + ); + await queryRunner.query( + `ALTER TABLE freight.files + DROP COLUMN IF EXISTS title, + DROP COLUMN IF EXISTS visible_to_customer, + DROP COLUMN IF EXISTS uploaded_by_user_id, + DROP COLUMN IF EXISTS uploaded_by_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts b/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts new file mode 100644 index 000000000..f587af9a7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTransitAgents3040000000000 implements MigrationInterface { + name = "AddTransitAgents3040000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.transit_agents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + valid_from date NOT NULL, + valid_to date NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active + ON freight.transit_agents (is_active) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 5e8b6aa0e..6245195eb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -317,11 +317,11 @@ export class BookingLifecycleNotifierService { }); } - /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + /** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */ finalInvoiceCreated(b: Booking, amount: number, currency: string): void { const msg = - `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + - `Please pay and upload the payment slip from the portal.`; + `A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` + + `Please review and approve it in the portal, then pay and upload the payment slip.`; void this.notifyContact(b, msg, 'FINAL INVOICE'); this.inApp(b, 'Final invoice issued', msg, { type: NotificationType.INVOICE_ISSUED, @@ -361,6 +361,15 @@ export class BookingLifecycleNotifierService { ); } + /** Customer approved the GL Djibouti final invoice — payment slip can follow. */ + finalInvoiceApprovedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Final invoice approved', + `The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`, + ); + } + /** Customer signed the booking contract. */ customerSignedToStaff(b: Booking): void { this.inAppStaff( @@ -392,16 +401,27 @@ export class BookingLifecycleNotifierService { ); } - /** - * The customer disputed the advised duty & tax. This goes to STAFF, not the - * customer: GL Ethiopia is the one who has to re-advise, and the clearance - * page is where they do it. - */ - dutyDisputed(b: Booking, note: string): void { + /** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */ + draftDeclarationReady(b: Booking, price: number, currency: string): void { const msg = - `The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` + - `"${note}". Review and re-advise the amount on the clearance page.`; - this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, { + `A draft customs declaration for booking ${b.reference} is ready for your review — ` + + `estimated price ${price} ${currency}. Please accept it or request a change from the portal.`; + void this.notifyContact(b, msg, 'DRAFT DECLARATION READY'); + this.inApp(b, 'Draft declaration ready for review', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** + * The customer asked for a change on the draft declaration. This goes to + * STAFF, not the customer: GL Ethiopia is the one who has to send a + * corrected draft, and the clearance page is where they do it. + */ + draftDeclarationChangeRequested(b: Booking, note: string): void { + const msg = + `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + + `"${note}". Send a corrected draft from the clearance page.`; + this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); 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 5b6bddbff..54ce1660e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -841,13 +841,13 @@ export class BookingsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', }) async assignBookingTransitAssignee( @Param('id', ParseUUIDPipe) id: string, - @Body('assignee') assignee: string, + @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, ) { - const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee); + const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId); return this.transitionService.enrichBookingResponse(booking); } @@ -900,17 +900,52 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty/dispute') + @Post(':id/clearance/draft-declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') @ApiOperation({ summary: - 'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)', + 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', }) - async disputeBookingDuty( + async uploadBookingDraftDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @Body('price') priceRaw: string, + @Body('currency') currency: string | undefined, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDraftDeclaration( + id, + files ?? [], + Number(priceRaw), + currency ?? 'ETB', + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/draft-declaration/accept') + @ApiOperation({ + summary: + 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', + }) + async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.acceptDraftDeclaration(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/draft-declaration/change') + @ApiOperation({ + summary: + 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', + }) + async requestBookingDraftDeclarationChange( @Param('id', ParseUUIDPipe) id: string, @Body('note') note: string, @CurrentUser() user: TCurrentUser, ) { - const booking = await this.bookingClearanceService.disputeDuty( + const booking = await this.bookingClearanceService.requestDraftDeclarationChange( id, note, resolveAuthUserId(user), diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts index 75007326e..969098196 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -7,10 +7,10 @@ export const REVIEW_NOTE_TYPES = [ 'REJECTION', 'STAFF_NOTE', /** - * The customer disputed the advised duty & tax and asked GL Ethiopia to - * correct it. One row per round — the advice/dispute loop can repeat. + * The customer asked GL Ethiopia to correct the draft customs declaration + * (price/files). One row per round — the draft/change-request loop can repeat. */ - 'DUTY_DISPUTE', + 'DRAFT_DECL_CHANGE_REQUEST', ] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 3d8776e80..df55493de 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -104,6 +104,12 @@ function makeService(overrides?: { transitAssigneeRequested: jest.fn(), transitAssigneeAssigned: jest.fn(), } as never, // notifier + { listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange + { + getAssignable: jest + .fn() + .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), + } as never, // transit agents ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index bfb44852a..c9c15f8b0 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,10 +1,13 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, + isDraftDeclarationFileCode, type ClearanceFinalInvoiceSummary, + type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, + type GlExchangeDocument, } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; @@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { GlExchangeService } from './gl-exchange.service'; +import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -89,10 +94,22 @@ export interface BookingClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; /** - * The customer's open objection to the advised duty. Present only until GL - * re-advises; `rounds` counts how many times it has been sent back. + * Import only: the draft customs declaration GL Ethiopia sends before filing + * the real one. Present once a draft has been uploaded, regardless of + * accept state — `accepted` tells the caller which. */ - dutyDispute?: { + draftDeclaration?: { + price: number; + currency: string; + files: Array<{ id: string; name: string; url: string }>; + accepted: boolean; + } | null; + /** + * The customer's open change request on the current draft declaration. + * Present only until GL sends a corrected draft; `rounds` counts how many + * times it has been sent back. + */ + draftDeclarationChangeRequest?: { note: string; raisedAt: string; rounds: number; @@ -107,6 +124,8 @@ export interface BookingClearanceView { t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; + /** Offload stats for this booking (what came off the train, and where). */ + offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ @@ -117,6 +136,8 @@ export interface BookingClearanceView { /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; + /** GL-shared documents this booking's uploader marked visible to the customer. */ + exchangeDocuments?: GlExchangeDocument[]; } @Injectable() @@ -131,6 +152,8 @@ export class BookingClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: BookingLifecycleNotifierService, + private readonly glExchangeService: GlExchangeService, + private readonly transitAgentsService: TransitAgentsService, ) {} private async assertPhasedCustoms(booking: Booking): Promise { @@ -231,7 +254,11 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); - const dutyDispute = await this.buildDutyDispute(bookingId, milestones); + const draftDeclaration = this.buildDraftDeclaration(files, milestones); + const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest( + bookingId, + milestones, + ); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', @@ -259,6 +286,12 @@ export class BookingClearanceService { const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + // GL↔GL exchange documents shared with the customer. The two desks may work + // the thread on the booking (per-booking customs) or on its contract + // (pre-booking clearance), so the customer's view spans both. + const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer( + [bookingId, booking.contractId ?? ''], + ); return { bookingId, @@ -301,8 +334,10 @@ export class BookingClearanceService { : null, }, dutyAdvice, - dutyDispute, + draftDeclaration, + draftDeclarationChangeRequest, workflowFiles, + exchangeDocuments, t1, train, gatepassGranted: gatepass.granted, @@ -313,6 +348,7 @@ export class BookingClearanceService { ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + offload: await this.glOperationsService.offloadState(bookingId, milestones), finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' @@ -359,13 +395,37 @@ export class BookingClearanceService { }; } - private async buildDutyDispute( + private buildDraftDeclaration( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['draftDeclaration'] { + const uploaded = milestones.find( + (m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED', + ); + if (!uploaded?.metadata) return null; + const price = uploaded.metadata.draftDeclarationPrice; + const currency = uploaded.metadata.draftDeclarationCurrency; + if (typeof price !== 'number' || typeof currency !== 'string') return null; + const draftFiles = files + .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')) + .map((f) => ({ id: f.id, name: f.name, url: f.url })); + const accepted = + milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status === + 'COMPLETED'; + return { price, currency, files: draftFiles, accepted }; + } + + private async buildDraftDeclarationChangeRequest( bookingId: string, milestones: ClearanceMilestone[], - ): Promise { - const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); - if (!advised || advised.status === 'COMPLETED') return null; - const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE'); + ): Promise { + const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); + if (!uploaded || uploaded.status === 'COMPLETED') return null; + const notes = await this.bookingsRepository.findReviewNotes( + bookingId, + 'DRAFT_DECL_CHANGE_REQUEST', + ); const latest = notes[0]; if (!latest) return null; return { @@ -426,28 +486,27 @@ export class BookingClearanceService { } /** - * GL Djibouti names the transit officer — free text, because the person is not - * a platform user. Answering unblocks the declaration for Ethiopia. A later - * call overwrites the name (reassignment) and re-notifies. + * GL Djibouti picks the transit officer from the admin-managed roster — + * rejected unless the agent is active and inside its validity window. + * Answering unblocks the declaration for Ethiopia. A later call overwrites + * the name (reassignment) and re-notifies. */ - async assignTransitAssignee(bookingId: string, assignee: string): Promise { + async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise { const booking = await this.loadBooking(bookingId); - if (!assignee?.trim()) { - throw new BadRequestException('Name the officer who will handle the transit.'); - } if (!booking.transitAssigneeRequestedAt) { throw new BadRequestException( 'GL Ethiopia has not requested a transit assignee for this shipment yet.', ); } + const agent = await this.transitAgentsService.getAssignable(transitAgentId); const previous = booking.transitAssigneeName ?? null; await this.bookingsRepository.update(bookingId, { - transitAssigneeName: assignee.trim(), + transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), } as never); - this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous); + this.notifier.transitAssigneeAssigned(booking, agent.name, previous); return this.bookingsService.findById(bookingId); } @@ -499,12 +558,6 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); - // Export: the declaration is the last GL ET pre-operation action — release - // immediately so the customer can proceed without a separate confirm click. - if (tradeDirection === 'EXPORT') { - await this.workflowService.onExportReleasedForBooking(bookingId, userId); - } - return this.bookingsService.findById(bookingId); } @@ -562,61 +615,120 @@ export class BookingClearanceService { } /** - * The customer disagrees with the advised duty & tax on this booking and asks - * GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so - * the Duty & tax step becomes actionable again on the GL clearance page, with - * the customer's message shown beside it. GL re-advises (same endpoint as the - * first time), which closes the dispute — the loop may run as many rounds as - * it takes. + * GL Ethiopia sends a draft customs declaration (estimated price + files) for + * the customer to review before the real declaration is filed. Repeatable — + * each call replaces the previous draft's files/price and re-arms the step, + * which is what a re-send after a change request needs. */ - async disputeDuty( + async uploadDraftDeclaration( + bookingId: string, + files: Express.Multer.File[], + price: number, + currency: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + if (files.length === 0) { + throw new BadRequestException('No draft declaration documents uploaded'); + } + if (!Number.isFinite(price) || price < 0) { + throw new BadRequestException('A valid estimated price is required.'); + } + // Backfills the two new milestone rows for bookings seeded before this step + // existed — a blind complete() 404s on a booking with no such row yet. + await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT'); + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DRAFT_DECLARATION_UPLOADED', + ); + + await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'DRAFT_DECLARATION_UPLOADED', + { draftDeclarationPrice: price, draftDeclarationCurrency: currency }, + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.draftDeclarationReady(updated, price, currency); + return updated; + } + + /** + * The customer accepts the draft declaration — GL Ethiopia may now file the + * real customs declaration. + */ + async acceptDraftDeclaration(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); + if (uploaded?.status !== 'COMPLETED') { + throw new BadRequestException('There is no draft declaration to accept yet.'); + } + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED'); + return this.bookingsService.findById(bookingId); + } + + /** + * The customer sends the draft declaration back with a reason. Nothing is + * filed; the upload milestone reopens so the step becomes actionable again + * for GL Ethiopia, with the customer's message shown beside it. GL re-sends + * (same endpoint as the first time), which closes the request — the loop may + * run as many rounds as it takes. + */ + async requestDraftDeclarationChange( bookingId: string, note: string, userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { - throw new BadRequestException('Duty applies only to import bookings.'); + throw new BadRequestException('Draft declaration applies only to import bookings.'); } if (!note?.trim()) { throw new BadRequestException( - 'Say what is wrong with the advised amount so GL can correct it.', + 'Say what needs to change so GL can correct the draft.', ); } - if (!booking.dutyRequired) { - throw new BadRequestException('Duty/tax is not required for this clearance.'); - } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { - throw new BadRequestException( - 'There is no advised duty amount to dispute yet.', - ); + if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') { + throw new BadRequestException('There is no draft declaration to request a change on yet.'); } - // Once the slip is in, the money is paid — a dispute then is a refund - // conversation, not a re-advice. - if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { + if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') { throw new BadRequestException( - 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', + 'The draft declaration has already been accepted — contact GL Ethiopia directly.', ); } await this.bookingsRepository.createReviewNote( bookingId, note.trim(), - 'DUTY_DISPUTE', + 'DRAFT_DECL_CHANGE_REQUEST', userId, ); - // Back to GL: reopening the milestone is what re-arms the Duty & tax step - // (the stepper picks its active step from milestone completion). - await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED'); + // Back to GL: reopening the milestone is what re-arms the step (the + // stepper picks its active step from milestone completion). + await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED'); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); const updated = await this.bookingsService.findById(bookingId); - this.notifier.dutyDisputed(updated, note.trim()); + this.notifier.draftDeclarationChangeRequested(updated, note.trim()); return updated; } @@ -824,6 +936,10 @@ export class BookingClearanceService { 'RELEASE_ORDER_SECURED', userId, ); + // Release Order is now the last GL DJ pre-operation action (it follows the + // declaration) — release immediately so booking creation unlocks without a + // separate confirm click. + await this.workflowService.onExportReleasedForBooking(bookingId, userId); return { booking: await this.bookingsService.findById(bookingId), hold: false }; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts deleted file mode 100644 index 4a3904182..000000000 --- a/apps/edr-freight-api/src/modules/contracts/booking-duty-dispute.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; - -import { BookingClearanceService } from './booking-clearance.service'; -import type { Booking } from '../bookings/entities/booking.entity'; - -/** - * The duty advice → dispute → re-advice loop, at the booking level. GL - * Ethiopia advises an amount; the customer either pays it or sends it back - * with a reason. Sending it back reopens the advice milestone — that is what - * puts the Duty & tax step back in GL's hands — and the round can repeat - * until the amount is agreed. - */ -describe('BookingClearanceService — duty dispute', () => { - const booking = (over: Partial = {}): Booking => - ({ - id: 'bk-1', - reference: 'BKG-2026-00042', - tradeDirection: 'IMPORT', - customsClearingEnabled: true, - contractId: 'ctr-1', - dutyRequired: true, - ...over, - }) as Booking; - - const milestone = (code: string, status: string) => - ({ milestoneCode: code, status }) as never; - - let repo: { - createReviewNote: jest.Mock; - findReviewNotes: jest.Mock; - update: jest.Mock; - }; - let bookingsService: { findById: jest.Mock }; - let workflowService: { listMilestonesForBooking: jest.Mock }; - let milestoneService: { reopenForBooking: jest.Mock }; - let notifier: { dutyDisputed: jest.Mock }; - let service: BookingClearanceService; - - const build = (milestones: unknown[]) => { - workflowService.listMilestonesForBooking.mockResolvedValue(milestones); - }; - - beforeEach(() => { - repo = { - createReviewNote: jest.fn().mockResolvedValue(undefined), - findReviewNotes: jest.fn().mockResolvedValue([]), - update: jest.fn().mockResolvedValue(undefined), - }; - bookingsService = { findById: jest.fn().mockResolvedValue(booking()) }; - workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) }; - milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) }; - notifier = { dutyDisputed: jest.fn() }; - - service = new BookingClearanceService( - repo as never, - bookingsService as never, - {} as never, // filesService - {} as never, // fileUploadSettingsService - workflowService as never, - milestoneService as never, - {} as never, // dropdownSettingsService - {} as never, // glOperationsService - notifier as never, - ); - build([ - milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), - milestone('DUTY_TAX_PAID', 'PENDING'), - ]); - }); - - it('records the objection and hands the step back to GL', async () => { - await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1'); - - expect(repo.createReviewNote).toHaveBeenCalledWith( - 'bk-1', - 'Declared value is wrong', - 'DUTY_DISPUTE', - 'user-1', - ); - // Reopening the advice milestone is what re-arms the Duty & tax step. - expect(milestoneService.reopenForBooking).toHaveBeenCalledWith( - 'bk-1', - 'DUTY_TAXES_ADVISED', - ); - expect(repo.update).toHaveBeenCalledWith('bk-1', { - clearanceCurrentPhase: 'GL_ET_OUTPUT', - }); - }); - - it('tells GL Ethiopia, not the customer', async () => { - await service.disputeDuty('bk-1', 'Too high', 'user-1'); - expect(notifier.dutyDisputed).toHaveBeenCalledWith( - expect.objectContaining({ id: 'bk-1' }), - 'Too high', - ); - }); - - it('requires a reason — GL cannot correct an unexplained objection', async () => { - await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf( - BadRequestException, - ); - expect(milestoneService.reopenForBooking).not.toHaveBeenCalled(); - }); - - it('refuses when nothing has been advised yet', async () => { - build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]); - await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( - /no advised duty amount/i, - ); - }); - - it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => { - build([ - milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), - milestone('DUTY_TAX_PAID', 'COMPLETED'), - ]); - await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( - /already been submitted/i, - ); - }); - - it('refuses when duty was never required for this clearance', async () => { - bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false })); - await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow( - /not required/i, - ); - }); - - describe('the view', () => { - const buildDispute = (milestones: unknown[]) => - ( - service as unknown as { - buildDutyDispute: (id: string, m: unknown[]) => Promise; - } - ).buildDutyDispute('bk-1', milestones); - - it('shows the objection while GL still owes a corrected advice', async () => { - repo.findReviewNotes.mockResolvedValue([ - { note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') }, - { note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, - ]); - - const dispute = await buildDispute([ - milestone('DUTY_TAXES_ADVISED', 'PENDING'), - ]); - - expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 }); - }); - - it('clears itself once GL re-advises', async () => { - repo.findReviewNotes.mockResolvedValue([ - { note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') }, - ]); - - const dispute = await buildDispute([ - milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), - ]); - - expect(dispute).toBeNull(); - }); - }); -}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 648d9666f..b3241d10c 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 @@ -18,6 +18,8 @@ const IMPORT_DEFS: Record> = { IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false }, PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true }, DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false }, + DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true }, + DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false }, UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false }, DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, 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 9b17a3e76..325987f0c 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 @@ -353,10 +353,10 @@ export class ClearanceWorkflowService { if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; if (tradeDirection === 'EXPORT') { + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone('RELEASE_ORDER_SECURED')) { return ContractDocPhase.GlDjCollection; } - if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; return ContractDocPhase.GlEtPostClearance; } @@ -450,13 +450,6 @@ export class ClearanceWorkflowService { : 'Proceed to request operation'; if (tradeDirection === 'EXPORT') { - if (!isDone('RELEASE_ORDER_SECURED')) { - return { - actor: 'GL_DJ', - action: 'Upload Release Order and vessel departure date', - milestoneCode: 'RELEASE_ORDER_SECURED', - }; - } if (!isDone('DECLARED')) { return { actor: 'GL_ET', @@ -464,6 +457,13 @@ export class ClearanceWorkflowService { milestoneCode: 'DECLARED', }; } + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } if (!isDone(EXPORT_BOUNDARY)) { return { actor: '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 31fc1f252..2ebd8fc70 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 @@ -7,6 +7,7 @@ import { import { ContractDocPhase, type ClearanceFinalInvoiceSummary, + type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, @@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; +import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { ClearanceMilestone, type RiskAssignmentRecord, @@ -140,6 +142,8 @@ export interface ContractClearanceView { t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; + /** Offload stats for the linked booking (null until one exists). */ + offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ @@ -165,6 +169,7 @@ export class ContractClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: ContractNotifierService, + private readonly transitAgentsService: TransitAgentsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -433,6 +438,9 @@ export class ContractClearanceService { ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + offload: cycle?.bookingId + ? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones) + : null, finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' @@ -1117,20 +1125,18 @@ export class ContractClearanceService { } /** - * GL Djibouti names the transit officer — free text, because the person is - * not a platform user. Answering unblocks the declaration for Ethiopia. A - * later call overwrites the name (reassignment) and re-notifies. + * GL Djibouti picks the transit officer from the admin-managed roster — + * rejected unless the agent is active and inside its validity window. + * Answering unblocks the declaration for Ethiopia. A later call overwrites + * the name (reassignment) and re-notifies. */ async assignTransitAssignee( contractId: string, - assignee: string, + transitAgentId: string, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); - if (!assignee?.trim()) { - throw new BadRequestException('Name the officer who will handle the transit.'); - } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); if (!cycle.transitAssigneeRequestedAt) { @@ -1138,16 +1144,17 @@ export class ContractClearanceService { 'GL Ethiopia has not requested a transit assignee for this clearance yet.', ); } + const agent = await this.transitAgentsService.getAssignable(transitAgentId); const previous = cycle.transitAssigneeName ?? null; await this.contractsRepository.updateCycle(cycle.id, { - transitAssigneeName: assignee.trim(), + transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), transitAssigneeAssignedByUserId: userId ?? null, }); const updated = await this.contractsService.findById(contractId); - this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous); + this.notifier.transitAssigneeAssigned(updated, agent.name, previous); return updated; } @@ -1186,6 +1193,15 @@ export class ContractClearanceService { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); await this.ensureDeclarationPrerequisites(contractId, contract); + // The draft-declaration accept/change-request loop only exists on the + // booking-scoped clearance page (portal customers never see contract-scoped + // clearance) — skip it here so it can never block the ONE_TIME pre-booking + // flow, which has no UI to complete it. Contracts seeded before this step + // existed have no such row to skip — ignore, `assertPriorComplete` below + // already tolerates a missing milestone as "not required". + await this.workflowService + .skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED']) + .catch(() => undefined); await this.workflowService.assertPriorComplete( contractId, contract.tradeDirection, @@ -1215,12 +1231,6 @@ export class ContractClearanceService { }); } - // Export: the declaration is the last GL ET pre-booking action — release - // immediately so booking creation unlocks without a separate confirm click. - if (contract.tradeDirection === 'EXPORT') { - await this.workflowService.onExportReleased(contractId, userId); - } - return this.contractsService.findById(contractId); } @@ -1561,6 +1571,10 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlEtOutput, }); await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + // Release Order is now the last GL DJ pre-booking action (it follows the + // declaration) — release immediately so booking creation unlocks without a + // separate confirm click. + await this.workflowService.onExportReleased(contractId, userId); return { contract: await this.contractsService.findById(contractId), hold: false }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts index 0ba682294..b86b08444 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => { {} as never, // dropdownSettingsService {} as never, // glOperationsService notifier as never, + {} as never, // transitAgentsService ); build([ milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), 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 ab330ae9c..8c004ade0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -837,16 +837,16 @@ export class ContractsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', }) assignTransitAssignee( @Param('id', ParseUUIDPipe) id: string, - @Body('assignee') assignee: string, + @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.assignTransitAssignee( id, - assignee, + transitAgentId, resolveAuthUserId(user), ); } @@ -1294,6 +1294,20 @@ export class ContractsController { ); } + @Post('bookings/:bookingId/final-invoice/approve') + @ApiOperation({ + summary: 'Customer approves the drafted final invoice — unlocks the payment slip', + }) + approveFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.approveFinalInvoice( + bookingId, + resolveAuthUserId(user), + ); + } + @Post('bookings/:bookingId/final-invoice-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') 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 679786c13..658acf39d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; +import { TransitAgentsModule } from '../transit-agents/transit-agents.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { GlExchangeController } from './gl-exchange.controller'; +import { GlExchangeService } from './gl-exchange.service'; import { BookingRequestService } from './booking-request.service'; import { BookingRequestRepository } from './booking-request.repository'; @@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // Provides the admin-editable contract document templates consumed by // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, + TransitAgentsModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), @@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum config.get('app.cbeExchange') ?? {}, }), ], - controllers: [ContractsController], + controllers: [ContractsController, GlExchangeController], providers: [ ContractsService, ContractsRepository, @@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractBookingService, ClearanceMilestoneService, GlOperationsService, + GlExchangeService, BookingRequestService, BookingRequestRepository, // Contract PDF providers (template resolution + render + PDF) — stateless @@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum BookingClearanceService, ContractBookingService, ClearanceMilestoneService, + GlExchangeService, ], }) export class ContractsModule {} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 14e3b86dd..57bcf4820 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -46,6 +46,9 @@ export interface MilestoneMetadata { declarationSerial?: string; /** When the gate pass was physically granted (GL DJ captures the time). */ gatepassAt?: string; + /** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */ + draftDeclarationPrice?: number; + draftDeclarationCurrency?: string; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts new file mode 100644 index 000000000..4875d9e24 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts @@ -0,0 +1,102 @@ +import { BadRequestException } from '@nestjs/common'; +import { Freight } from '@edr/types'; + +import { GlOperationsService } from './gl-operations.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The GL Djibouti final invoice lands as a DRAFT: the customer must approve it + * (which issues it) before a payment slip is accepted. + */ +describe('GlOperationsService — final invoice approval', () => { + const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({ + id: 'inv-1', + invoiceNumber: 'INV-1', + status, + totalAmount: 1500, + currency: 'ETB', + issuedAt, + paidAt: null, + }); + + let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock }; + let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock }; + let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock }; + let service: GlOperationsService; + + beforeEach(() => { + billingService = { + findInvoice: jest.fn(), + updateStatus: jest.fn().mockResolvedValue(undefined), + }; + filesService = { + findByResource: jest.fn().mockResolvedValue([]), + upsertByCode: jest.fn().mockResolvedValue(undefined), + }; + notifier = { + finalInvoiceApprovedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + }; + const dataSource = { + getRepository: (entity: unknown) => + entity === Booking + ? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) } + : { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) }, + }; + + service = new GlOperationsService( + dataSource as never, + filesService as never, + {} as never, // milestoneService + billingService as never, + notifier as never, + ); + }); + + it('issues the draft on customer approval and reports approvedAt', async () => { + const issued = new Date('2026-07-28T09:00:00.000Z'); + billingService.findInvoice + .mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft)) + .mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued)); + + const summary = await service.approveFinalInvoice('bk-1', 'user-1'); + + expect(billingService.updateStatus).toHaveBeenCalledWith( + 'inv-1', + Freight.InvoiceStatus.Issued, + ); + expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled(); + expect(summary.approvedAt).toBe(issued.toISOString()); + }); + + it('is a no-op when the invoice was already approved', async () => { + billingService.findInvoice.mockResolvedValue( + invoice(Freight.InvoiceStatus.Issued, new Date()), + ); + + await service.approveFinalInvoice('bk-1'); + + expect(billingService.updateStatus).not.toHaveBeenCalled(); + }); + + it('refuses a payment slip while the invoice is still a draft', async () => { + billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft)); + + await expect( + service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never), + ).rejects.toThrow(BadRequestException); + expect(filesService.upsertByCode).not.toHaveBeenCalled(); + }); + + it('accepts the payment slip once approved', async () => { + billingService.findInvoice.mockResolvedValue( + invoice(Freight.InvoiceStatus.Issued, new Date()), + ); + + await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ code: 'final_invoice_slip' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts new file mode 100644 index 000000000..6ea0dacbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts @@ -0,0 +1,133 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + 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 { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { actorLabel } from '../warehouses/current-actor.util'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; + +import { + GlExchangeService, + type GlExchangeActor, + type GlExchangeSide, +} from './gl-exchange.service'; + +/** Either GL desk may read and post; ownership decides who may edit. */ +const GL_EXCHANGE_PERMS = [ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, +]; + +/** Multipart bodies arrive as strings — "true"/"1" mean checked. */ +const asBool = (raw: string | boolean | undefined): boolean => + raw === true || raw === 'true' || raw === '1'; + +@ApiTags('gl-exchange') +@ApiBearerAuth() +@Controller('gl-exchange') +export class GlExchangeController { + constructor(private readonly exchangeService: GlExchangeService) {} + + @Get(':entityId') + @BookingStaff(GL_EXCHANGE_PERMS) + @ApiOperation({ + summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', + }) + list( + @Param('entityId', ParseUUIDPipe) entityId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.list(entityId, resolveAuthUserId(user)); + } + + @Post(':entityId') + @BookingStaff(GL_EXCHANGE_PERMS) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Share a document with the other GL desk' }) + upload( + @Param('entityId', ParseUUIDPipe) entityId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body('title') title: string, + @Body('visibleToCustomer') visibleToCustomer: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.upload( + entityId, + file, + { title, visibleToCustomer: asBool(visibleToCustomer) }, + this.actor(user), + ); + } + + @Patch('documents/:documentId') + @BookingStaff(GL_EXCHANGE_PERMS) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Uploader edits a shared document (title, visibility, file)', + }) + update( + @Param('documentId', ParseUUIDPipe) documentId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body('title') title: string | undefined, + @Body('visibleToCustomer') visibleToCustomer: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.update( + documentId, + { + title, + visibleToCustomer: + visibleToCustomer == null ? undefined : asBool(visibleToCustomer), + }, + file, + resolveAuthUserId(user), + ); + } + + @Delete('documents/:documentId') + @BookingStaff(GL_EXCHANGE_PERMS) + @HttpCode(204) + @ApiOperation({ summary: 'Uploader removes a shared document' }) + async remove( + @Param('documentId', ParseUUIDPipe) documentId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.exchangeService.remove(documentId, resolveAuthUserId(user)); + } + + /** + * Which desk is posting. A user holding only the Djibouti actions permission + * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) + * posts as Ethiopia. + */ + private actor(user: TCurrentUser): GlExchangeActor { + const side: GlExchangeSide = + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) + ? 'DJ' + : 'ET'; + return { + userId: resolveAuthUserId(user), + name: actorLabel(user) ?? null, + side, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts new file mode 100644 index 000000000..1b0d88b2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts @@ -0,0 +1,198 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { Freight } from '@edr/types'; + +import { FilesService } from '../files/files.service'; +import type { FileRecord } from '../files/entities/file.entity'; + +/** + * `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The + * thread is keyed by the entity the two desks are working on — a booking id on + * the per-booking clearance pages, a contract id on the pre-booking ones — so + * both desks opening the same record see the same documents. + */ +export const GL_EXCHANGE_RESOURCE = 'gl_exchange'; + +export type GlExchangeSide = 'ET' | 'DJ'; + +export interface GlExchangeActor { + userId: string; + name?: string | null; + side: GlExchangeSide; +} + +export interface GlExchangeUploadInput { + title: string; + visibleToCustomer: boolean; +} + +/** + * Free-form document exchange between the two Global Logistics desks. Anything + * either side needs the other to have (scans, correspondence, corrected forms) + * lands here under a title they choose, instead of a fixed clearance slot. + * + * Rules, all enforced here rather than in the UI: + * - both desks read every document in a thread, whoever uploaded it; + * - only the uploader may retitle, replace or remove one; + * - the customer sees only what its uploader marked visible. + */ +@Injectable() +export class GlExchangeService { + constructor(private readonly filesService: FilesService) {} + + /** Every document on one thread, newest first, from a GL desk's view. */ + async list( + entityId: string, + viewerId: string, + ): Promise { + const records = await this.filesService.findByResource( + entityId, + GL_EXCHANGE_RESOURCE, + ); + return this.sort(records.map((r) => this.toDto(r, viewerId))); + } + + /** + * The customer-facing slice across several threads (a booking and the + * contract it belongs to). Never exposes internal documents, and never marks + * anything editable — the customer is not a GL desk. + */ + async listVisibleToCustomer( + entityIds: string[], + ): Promise { + const ids = [...new Set(entityIds.filter(Boolean))]; + if (ids.length === 0) return []; + const grouped = await this.filesService.findByResourceIdsGrouped( + ids, + GL_EXCHANGE_RESOURCE, + ); + const visible = [...grouped.values()] + .flat() + .filter((r) => r.visibleToCustomer); + return this.sort(visible.map((r) => this.toDto(r, null))); + } + + async upload( + entityId: string, + file: Express.Multer.File | undefined, + input: GlExchangeUploadInput, + actor: GlExchangeActor, + ): Promise { + const title = input.title?.trim(); + if (!title) throw new BadRequestException('A document title is required.'); + if (!file) throw new BadRequestException('A file is required.'); + + const record = await this.filesService.upload({ + resourceId: entityId, + resource: GL_EXCHANGE_RESOURCE, + // No fixed slot exists for these — `code` carries the uploading desk, so + // a document's origin survives even if the uploader leaves the org. + code: actor.side, + file, + title, + visibleToCustomer: input.visibleToCustomer, + uploadedByUserId: actor.userId, + uploadedByName: actor.name ?? null, + }); + return this.toDto(record, actor.userId); + } + + /** + * Retitle, re-share or replace a document. Uploader only — the other desk + * reads it but never edits it. A replacement file supersedes the old record + * (soft-deleted, bytes kept) and carries its metadata forward. + */ + async update( + documentId: string, + patch: { title?: string; visibleToCustomer?: boolean }, + file: Express.Multer.File | undefined, + actorId: string, + ): Promise { + const record = await this.assertUploader(documentId, actorId); + const title = patch.title?.trim(); + if (patch.title != null && !title) { + throw new BadRequestException('A document title is required.'); + } + + if (file) { + const replacement = await this.filesService.upload({ + resourceId: record.resourceId, + resource: GL_EXCHANGE_RESOURCE, + code: record.code, + file, + title: title ?? record.title, + visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer, + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + }); + await this.filesService.remove(record.id); + return this.toDto(replacement, actorId); + } + + const updated = await this.filesService.updateMeta(record.id, { + ...(title ? { title } : {}), + ...(patch.visibleToCustomer != null + ? { visibleToCustomer: patch.visibleToCustomer } + : {}), + }); + return this.toDto(updated, actorId); + } + + /** Uploader-only removal (soft delete — the stored bytes are kept). */ + async remove(documentId: string, actorId: string): Promise { + const record = await this.assertUploader(documentId, actorId); + await this.filesService.remove(record.id); + } + + private async assertUploader( + documentId: string, + actorId: string, + ): Promise { + const record = await this.filesService.findById(documentId); + if (record.resource !== GL_EXCHANGE_RESOURCE) { + throw new NotFoundException(`Exchange document ${documentId} not found`); + } + if (record.uploadedByUserId !== actorId) { + throw new ForbiddenException( + 'Only the person who uploaded this document can change it.', + ); + } + return record; + } + + private sort( + docs: Freight.GlExchangeDocument[], + ): Freight.GlExchangeDocument[] { + return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)); + } + + private toDto( + record: FileRecord, + viewerId: string | null, + ): Freight.GlExchangeDocument { + return { + id: record.id, + entityId: record.resourceId, + // Pre-title rows (none in practice) fall back to the filename so a list + // never renders a blank row. + title: record.title ?? record.name, + side: record.code === 'DJ' ? 'DJ' : 'ET', + visibleToCustomer: record.visibleToCustomer, + uploadedById: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + uploadedAt: record.createdAt.toISOString(), + file: { + id: record.id, + name: record.name, + url: record.url, + size: record.size, + mimeType: record.mimeType, + }, + canEdit: viewerId != null && record.uploadedByUserId === viewerId, + }; + } +} 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 106acdb0b..c7be2372b 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 @@ -282,6 +282,85 @@ export class GlOperationsService { }; } + /** + * Offload facts for a booking, read-only: what came off the train at its + * destination (containers, wagons, tonnes) and where the goods went. Sourced + * from the booking's warehouse-inventory row — written by the auto-unload + * that runs on train arrival for both directions. + */ + async offloadState( + bookingId: string, + milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>, + ): Promise { + const [row]: Array<{ + destination: string | null; + containers: number; + wagons: number; + bookedWeight: string | null; + inventoryStatus: string | null; + unloadedAt: Date | null; + grnNumber: string | null; + offloadedWeight: string | null; + warehouse: string | null; + warehouseYard: string | null; + zone: string | null; + }> = await this.dataSource.query( + `SELECT COALESCE(dy.label, dy.code) AS "destination", + (SELECT COUNT(*)::int + FROM freight.booking_container bc + JOIN freight.booking_container_units bcu + ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL + WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers", + (SELECT COUNT(*)::int + FROM freight.wagon_booking_allocations wba + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons", + b.cargo_total_weight_vgm AS "bookedWeight", + inv.status AS "inventoryStatus", + inv.unloaded_at AS "unloadedAt", + inv.grn_number AS "grnNumber", + inv.weight AS "offloadedWeight", + wh.name AS "warehouse", + wy.name AS "warehouseYard", + wz.name AS "zone" + FROM freight.bookings b + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN LATERAL ( + SELECT i.* + FROM freight.warehouse_inventory i + WHERE i.booking_id = b.id AND i.deleted_at IS NULL + ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC + LIMIT 1 + ) inv ON TRUE + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id + LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + + const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED'); + const offloadedAt = + milestone?.status === 'COMPLETED' && milestone.triggeredAt + ? new Date(milestone.triggeredAt).toISOString() + : (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null); + // The warehouse records the real offloaded tonnage; before it does, the + // booked VGM is the best number we have. + const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0); + const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › '); + + return { + offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt), + offloadedAt, + destination: row?.destination ?? null, + containers: row?.containers ?? 0, + wagons: row?.wagons ?? 0, + weightTons: weight || null, + grnNumber: row?.grnNumber ?? null, + location: location || null, + inventoryStatus: row?.inventoryStatus ?? null, + }; + } + /** * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass * is secured on the train schedule (which itself follows wagon allocation). @@ -381,8 +460,9 @@ export class GlOperationsService { /** * GL Djibouti raises the post-offload final invoice (export): manual amount + - * attached invoice document. The customer pays offline and attaches a slip; - * GL (ET or DJ) then confirms to settle it. + * attached invoice document. It is issued as a DRAFT the customer must approve + * first; only then do they pay offline and attach a slip, and GL (ET or DJ) + * confirms to settle it. */ async createFinalInvoice( bookingId: string, @@ -445,7 +525,8 @@ export class GlOperationsService { amount: input.amount, }, ], - status: Freight.InvoiceStatus.Issued, + // DRAFT until the customer approves it — approveFinalInvoice issues it. + status: Freight.InvoiceStatus.Draft, }); await this.filesService.upsertByCode({ @@ -467,6 +548,40 @@ export class GlOperationsService { return summary; } + /** + * Customer approves the drafted final invoice — issues it, which is what + * unlocks the payment slip upload. Idempotent: approving twice is a no-op. + */ + async approveFinalInvoice( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been raised for this shipment.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + if (invoice.status === Freight.InvoiceStatus.Draft) { + await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued); + this.notifier.finalInvoiceApprovedToStaff(booking); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + /** Customer attaches the payment slip for the final invoice. */ async uploadFinalInvoiceSlip( bookingId: string, @@ -483,6 +598,11 @@ export class GlOperationsService { if (!invoice) { throw new BadRequestException('No final invoice has been issued for this shipment.'); } + if (invoice.status === Freight.InvoiceStatus.Draft) { + throw new BadRequestException( + 'Approve the final invoice before attaching a payment slip.', + ); + } if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException('The final invoice is already paid.'); } @@ -688,6 +808,8 @@ export class GlOperationsService { description: line?.description ?? null, invoiceFile: toRef('final_invoice'), slipFile: toRef('final_invoice_slip'), + // Issuing IS the customer approval (createFinalInvoice leaves it DRAFT). + approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null, confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : 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 index f155a27be..78d0d6b47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common'; import { catalogEntriesForTradeDirection, declarationFileLabel, + draftDeclarationFileLabel, isDeclarationFileCode, + isDraftDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, @@ -72,6 +74,52 @@ export async function persistDeclarationUploads( ); } +/** Require at least one draft declaration file in the upload batch. */ +export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No draft declaration documents uploaded'); + } +} + +/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDraftDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `draft_declaration_${index}`, + })); +} + +/** Replace all draft declaration files on a resource with a new multi-file upload batch. */ +export async function persistDraftDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDraftDeclarationFieldNames(files); + assertDraftDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `draft_declaration_${index}`, + file, + }), + ), + ); +} + /** Require at least one transit permit file in the upload batch. */ export function assertTransitPermitFiles(files: Express.Multer.File[]): void { if (files.length === 0) { @@ -341,6 +389,22 @@ export function buildWorkflowFiles( }); }); + const extraDraftDeclarations = files + .filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDraftDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: draftDeclarationFileLabel(index), + uploadedBy: 'gl_et', + category: 'draft_declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + if (tradeDirection === 'IMPORT') { const extraTransit = files .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index da4b2f34b..a6c8c8d6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => { transitAssigneeRequested: jest.Mock; transitAssigneeAssigned: jest.Mock; }; + let transitAgentsService: { getAssignable: jest.Mock }; let service: ContractClearanceService; const cycle = (over: Record = {}) => ({ @@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => { transitAssigneeRequested: jest.fn(), transitAssigneeAssigned: jest.fn(), }; + transitAgentsService = { + getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }), + }; service = new ContractClearanceService( repo as never, contractsService as never, @@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => { {} as never, {} as never, notifier as never, + transitAgentsService as never, ); }); @@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => { cycle({ transitAssigneeRequestedAt: new Date() }), ); - await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1'); + await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'); + expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1'); const patch = repo.updateCycle.mock.calls[0][1]; expect(patch.transitAssigneeName).toBe('Ahmed Bourhan'); expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1'); @@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => { transitAssigneeName: 'Ahmed Bourhan', }), ); + transitAgentsService.getAssignable.mockResolvedValue({ + id: 'agent-2', + name: 'Fatouma Ali', + }); - await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1'); + await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1'); expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( expect.anything(), @@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => { ); }); - it('refuses an empty name', async () => { + it('refuses a suspended or out-of-window agent', async () => { repo.currentCycle.mockResolvedValue( cycle({ transitAssigneeRequestedAt: new Date() }), ); + transitAgentsService.getAssignable.mockRejectedValue( + new BadRequestException('suspended'), + ); await expect( - service.assignTransitAssignee('ctr-1', ' ', 'dj-1'), + service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'), ).rejects.toBeInstanceOf(BadRequestException); }); it('refuses before Ethiopia has asked', async () => { await expect( - service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'), + service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'), ).rejects.toThrow(/not requested/i); + expect(transitAgentsService.getAssignable).not.toHaveBeenCalled(); }); }); diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 7624800d8..4e2d40f55 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity { /** Why the file was replaced — shown on the document's version history. */ @Column({ name: "replace_reason", type: "text", nullable: true }) replaceReason!: string | null; + + /** + * Free-text label chosen by the uploader, when the document has no fixed slot + * (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every + * catalog-driven upload, whose label comes from its code. + */ + @Column({ name: "title", type: "varchar", length: 300, nullable: true }) + title!: string | null; + + /** Uploader's choice to share the document with the customer's portal. */ + @Column({ name: "visible_to_customer", type: "boolean", default: false }) + visibleToCustomer!: boolean; + + /** Who uploaded it — the only user allowed to edit or remove it afterwards. */ + @Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true }) + uploadedByUserId!: string | null; + + /** Uploader's display name, resolved once so lists need no IAM lookup. */ + @Column({ + name: "uploaded_by_name", + type: "varchar", + length: 200, + nullable: true, + }) + uploadedByName!: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index ea11e5fe8..bc446b508 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -15,6 +15,11 @@ export interface CreateFileInput { resource: string; code: string; file: Express.Multer.File; + /** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */ + title?: string | null; + visibleToCustomer?: boolean; + uploadedByUserId?: string | null; + uploadedByName?: string | null; } /** @@ -101,9 +106,27 @@ export class FilesService { url, size: file.size, mimeType: file.mimetype, + title: input.title ?? null, + visibleToCustomer: input.visibleToCustomer ?? false, + uploadedByUserId: input.uploadedByUserId ?? null, + uploadedByName: input.uploadedByName ?? null, }); } + /** + * Edit the uploader-authored metadata of a stored file (title, customer + * visibility). Bytes are untouched — callers replacing content upload a new + * record instead. + */ + async updateMeta( + id: string, + patch: { title?: string; visibleToCustomer?: boolean }, + ): Promise { + const updated = await this.filesRepository.update(id, patch); + if (!updated) throw new NotFoundException(`File ${id} not found`); + return updated; + } + /** * Replace the file stored under a resource + code (e.g. contract PDF). The * previous version is retired, not destroyed — pass `replacedBy` to record who diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts new file mode 100644 index 000000000..be3810c0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +export class CreateTransitAgentDto { + @ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) + @IsString() + @MaxLength(150) + name!: string; + + @ApiProperty({ example: '2026-01-01' }) + @IsDateString() + validFrom!: string; + + @ApiProperty({ example: '2026-12-31' }) + @IsDateString() + validTo!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts new file mode 100644 index 000000000..7e18a93da --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTransitAgentDto } from './create-transit-agent.dto'; + +export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts new file mode 100644 index 000000000..6d0ef9158 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Djibouti transit officer GL Djibouti may assign against a shipment's + * transit-assignee handshake. Admin-managed so the roster and each officer's + * validity window arrive without a code change; `isActive` is the manual + * suspend/reactivate switch, independent of the validity window. + */ +@Entity({ schema: 'freight', name: 'transit_agents' }) +@Index(['isActive']) +export class TransitAgent extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'valid_from', type: 'date' }) + validFrom!: string; + + @Column({ name: 'valid_to', type: 'date' }) + validTo!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts new file mode 100644 index 000000000..4f4b90c7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + RuleEngineCreate, + RuleEngineDelete, + RuleEngineUpdate, + RuleEngineView, +} from '../../common/rule-engine-guards'; + +import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; +import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; +import { TransitAgentsService } from './transit-agents.service'; + +@ApiTags('transit-agents') +@Controller('transit-agents') +@ApiBearerAuth() +export class TransitAgentsController { + constructor(private readonly transitAgentsService: TransitAgentsService) {} + + @Get() + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'List transit agents' }) + findAll(@Query() query: Record) { + return this.transitAgentsService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : undefined, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + /** Active + currently valid officers — the transit-assignee assignment dropdown. */ + @Get('assignable') + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) + findAssignable() { + return this.transitAgentsService.findAssignable(); + } + + @Get(':id') + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'Get a transit agent by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.transitAgentsService.findById(id); + } + + @Post() + @RuleEngineCreate('transit-agents') + @ApiOperation({ summary: 'Create a transit agent' }) + create(@Body() dto: CreateTransitAgentDto) { + return this.transitAgentsService.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('transit-agents') + @ApiOperation({ summary: 'Update a transit agent' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { + return this.transitAgentsService.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('transit-agents') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a transit agent' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.transitAgentsService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts new file mode 100644 index 000000000..47e655e94 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgentsController } from './transit-agents.controller'; +import { TransitAgentsRepository } from './transit-agents.repository'; +import { TransitAgentsService } from './transit-agents.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TransitAgent])], + controllers: [TransitAgentsController], + providers: [TransitAgentsRepository, TransitAgentsService], + exports: [TransitAgentsRepository, TransitAgentsService], +}) +export class TransitAgentsModule {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts new file mode 100644 index 000000000..4418ad938 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -0,0 +1,28 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; + +import { TransitAgent } from './entities/transit-agent.entity'; + +@Injectable() +export class TransitAgentsRepository extends BaseRepository { + constructor( + @InjectRepository(TransitAgent) + repository: Repository, + ) { + super(repository); + } + + /** Active AND currently inside its validity window (today's date, server-side). */ + findAssignable(today: string): Promise { + return this.repository.find({ + where: { + isActive: true, + validFrom: LessThanOrEqual(today), + validTo: MoreThanOrEqual(today), + }, + order: { name: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts new file mode 100644 index 000000000..ec9c24e9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -0,0 +1,138 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; +import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; +import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgentsRepository } from './transit-agents.repository'; + +export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED'; + +export type TransitAgentView = TransitAgent & { + validityStatus: TransitAgentValidityStatus; +}; + +type TransitAgentListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */ +function todayISODate(): string { + return new Date().toISOString().slice(0, 10); +} + +function validityStatus(agent: Pick): TransitAgentValidityStatus { + const today = todayISODate(); + if (today < agent.validFrom) return 'NOT_STARTED'; + if (today > agent.validTo) return 'EXPIRED'; + return 'VALID'; +} + +function withValidityStatus(agent: TransitAgent): TransitAgentView { + return { ...agent, validityStatus: validityStatus(agent) }; +} + +@Injectable() +export class TransitAgentsService { + constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {} + + async findAll(filter: TransitAgentListFilter = {}): Promise<{ + data: TransitAgentView[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '') + ? (filter.sortBy as keyof TransitAgent) + : 'name'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.transitAgentsRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data: data.map(withValidityStatus), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** Active and currently inside its validity window — the DJ assignment dropdown. */ + async findAssignable(): Promise { + return this.transitAgentsRepository.findAssignable(todayISODate()); + } + + async findById(id: string): Promise { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + return withValidityStatus(agent); + } + + /** Used by the assignment flow — rejects a suspended or out-of-window officer. */ + async getAssignable(id: string): Promise { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new BadRequestException('Selected transit officer was not found.'); + } + if (!agent.isActive) { + throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`); + } + if (validityStatus(agent) !== 'VALID') { + throw new BadRequestException( + `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, + ); + } + return agent; + } + + async create(dto: CreateTransitAgentDto): Promise { + if (dto.validTo < dto.validFrom) { + throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + } + const agent = await this.transitAgentsRepository.create({ + name: dto.name.trim(), + validFrom: dto.validFrom, + validTo: dto.validTo, + isActive: dto.isActive ?? true, + }); + return withValidityStatus(agent); + } + + async update(id: string, dto: UpdateTransitAgentDto): Promise { + const current = await this.findById(id); + const nextValidFrom = dto.validFrom ?? current.validFrom; + const nextValidTo = dto.validTo ?? current.validTo; + if (nextValidTo < nextValidFrom) { + throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + } + + const updated = await this.transitAgentsRepository.update(id, { + ...dto, + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + return withValidityStatus(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.transitAgentsRepository.softDelete(id); + } +} 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 eaf2f1c02..788479329 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -22,6 +22,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ // Keep new slugs at the END: ruleEngineCrudId derives ids from list index, // so a mid-list insert would shift ids already seeded for later slugs. 'truck-types', + 'transit-agents', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -123,6 +124,7 @@ const RULE_ENGINE_VIEW_IDS: Record = { rates: 'b2000001-0001-4000-8000-000000000011', 'approval-rules': 'b2000001-0001-4000-8000-000000000013', 'yard-distances': 'b2000001-0001-4000-8000-000000000018', + 'transit-agents': 'b2000001-0001-4000-8000-00000000001b', }; // CRUD replaces the retired coarse `:manage`. New ids live in a fresh block diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx index c9290bb9b..610fb7e3d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx @@ -1,12 +1,15 @@ import type { ReactNode } from "react"; import { Badge, Stack, Tabs, Text } from "@mantine/core"; -import { AlertTriangle, FileText, ShieldAlert } from "lucide-react"; +import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard"; import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; +import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; export interface ClearanceOpsTabsProps { bookingId: string | undefined; @@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps { /** Phased customs workflow files — enables the Uploaded documents tab. */ workflowFiles?: Freight.ClearanceWorkflowFile[]; showWorkflowFilesTab?: boolean; + /** + * Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange + * belongs on this page. Undefined hides the tab; it is also hidden from staff + * who hold neither desk's clearance-actions permission. + */ + exchangeEntityId?: string; tradeDirection?: string; onViewFile?: (file: { name: string; url: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void; @@ -40,6 +49,7 @@ export function ClearanceOpsTabs({ clearanceTab, workflowFiles = [], showWorkflowFilesTab = false, + exchangeEntityId, tradeDirection = "IMPORT", onViewFile, onDownloadFile, @@ -53,7 +63,12 @@ export function ClearanceOpsTabs({ return true; }).length; const showDocuments = showWorkflowFilesTab && Boolean(onViewFile); - const hasTabs = (showOpsTabs && hasOps) || showDocuments; + const { user } = useAuth(); + const showExchange = + Boolean(exchangeEntityId) && + (hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || + hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)); + const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange; if (!hasTabs) { return <>{clearanceTab}; @@ -78,6 +93,11 @@ export function ClearanceOpsTabs({ Uploaded documents ) : null} + {showExchange ? ( + }> + Document exchange + + ) : null} {showOpsTabs && riskMs ? ( }> Risk assignment @@ -103,6 +123,12 @@ export function ClearanceOpsTabs({ ) : null} + {showExchange ? ( + + + + ) : null} + {showOpsTabs && riskMs && bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx index 8428edf52..8ff9dffcc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx @@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record< string > = { declaration: "Declaration", + draft_declaration: "Draft declaration", duty: "Duty & taxes", transit: "Transit", djibouti: "Djibouti", }; const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [ + "draft_declaration", "declaration", "duty", "transit", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index df8871b07..61f8251c9 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -20,6 +20,7 @@ import { CheckCircle2, FileText, PackageCheck, + PackageOpen, Receipt, Ship, Train, @@ -31,6 +32,7 @@ import toast from "react-hot-toast"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel"; import { TransitPermitMultiUpload, type TransitPermitUploadedRow, @@ -41,9 +43,11 @@ import { } from "@/components/contracts/PhasedUploadedFileRow"; import { DeclarationStep, + OffloadStep, StepStatus, isBookingMilestoneDone, isMilestoneDone, + offloadSummary, type ClearanceViewLike, type MilestoneRow, } from "@/components/contracts/PhasedClearanceActionPanel"; @@ -59,7 +63,8 @@ function todayISODate(): string { /** * Export customs flow, ordered per the stakeholder process: - * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) + * customer docs → transit assignee (DJ names officer) → declaration (ET, + * releases the export) → RO (DJ, auto-releases) → create booking (ET) * → payment + wagons → transport document / T1 (ET) → train to Djibouti * → accept T1 (DJ, one button after arrival) → gate pass (DJ) * → final invoice (DJ) + customer slip + GL confirm. @@ -71,9 +76,10 @@ export function computeExportActiveStep( ): number { const released = Boolean(clearance.bookingReady || clearance.operationReady); if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; - if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1; - if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2; - if (!bookingCreated) return 3; + if (!clearance.transitAssignee?.name) return 1; + if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 2; + if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") || !released) return 3; + if (!bookingCreated) return 4; if ( !isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") || !( @@ -81,14 +87,17 @@ export function computeExportActiveStep( clearance.train?.wagonAllocated ) ) { - return 4; + return 5; } - if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; - if (!clearance.train?.arrivedAt) return 6; - if (!clearance.t1Closed) return 7; - if (!clearance.gatepassGranted) return 8; - if (clearance.finalInvoice?.status !== "PAID") return 9; - return 10; + if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 6; + if (!clearance.train?.arrivedAt) return 7; + if (!clearance.t1Closed) return 8; + if (!clearance.gatepassGranted) return 9; + // Step 10 is the read-only Offload step. It never gates the flow: the final + // invoice may be raised on a secured gate pass alone, so parking the stepper + // there would hide the invoice actions whenever operations lag on the offload. + if (clearance.finalInvoice?.status !== "PAID") return 11; + return 12; } export function exportTransitFilesFromWorkflow( @@ -168,6 +177,7 @@ export function ExportClearanceStepper({ isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || Boolean(clearance.train?.wagonAllocated); const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED"); + const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded); return ( @@ -213,6 +223,58 @@ export function ExportClearanceStepper({ /> + + ) : undefined + } + > + {showEt && canEt ? ( + + ) : ( + + )} + + + : } + > + {showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? ( + + + + ) : ( + + )} + + ) : null} - - )} - - - : } - > - {showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? ( - - - {declared && !released ? ( + {/* RO is secured but the auto-release never fired (legacy in-flight + contracts from before the RO step auto-released). */} + {isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && !released ? ( ) : null} - ) : ( - )} @@ -432,6 +470,21 @@ export function ExportClearanceStepper({ + {/* Read-only: operations record the offload when the train is unloaded + at the Djibouti port. Stats ride in the description so they stay + visible after the flow moves on to the final invoice. */} + } + completedIcon={ + offloadDone ? : + } + > + + + - {invoice.status} + {approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"} @@ -722,9 +777,11 @@ function FinalInvoiceStep({ @@ -754,6 +811,7 @@ function FinalInvoiceStep({ <> Send the final invoice to the customer if post-arrival charges apply (optional). + The customer approves it before paying. + + + {documents.length > 0 ? ( + + + {stats.et} from GL Ethiopia + + + {stats.dj} from GL Djibouti + + + {stats.shared} visible to customer + + + ) : null} + + + {isLoading ? ( + + + + Loading shared documents… + + + ) : isError ? ( + + Could not load the shared documents. + + ) : documents.length === 0 ? ( + setFormDoc("new")} /> + ) : ( + + {documents.map((doc) => ( + setFormDoc(doc)} + onDelete={() => setPendingDelete(doc)} + /> + ))} + + )} + + setFormDoc(null)} + onSaved={() => { + setFormDoc(null); + void invalidate(); + }} + /> + + setPendingDelete(null)} + title={Remove shared document} + radius="md" + size="sm" + > + + + Remove {pendingDelete?.title} from the exchange? The other + desk — and the customer, if it was shared — will no longer see it. + + + + + + + + + {viewer} + + ); +} + +function EmptyState({ onShare }: { onShare: () => void }) { + return ( + + + + + + + Nothing shared yet. Anything either desk uploads here — scans, + correspondence, corrected forms — is visible to the other side + immediately. + + + + + ); +} + +function DocumentRow({ + doc, + onView, + onEdit, + onDelete, +}: { + doc: Freight.GlExchangeDocument; + onView: (file: { name: string; url: string }) => void; + onEdit: () => void; + onDelete: () => void; +}) { + const side = SIDES[doc.side]; + const canPreview = isViewable({ name: doc.file.name, url: "" }); + + return ( + + + + + + + + + + {doc.title} + + + {side.label} + + : + } + > + {doc.visibleToCustomer ? "Visible to customer" : "GL only"} + + + + {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} + {doc.uploadedByName ?? "Global Logistics"} ·{" "} + {dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")} + + + + + + {canPreview ? ( + + + + ) : null} + + + + {doc.canEdit ? ( + + + + + + + + } onClick={onEdit}> + Edit title, visibility or file + + } + onClick={onDelete} + > + Remove + + + + ) : ( + + + + + + )} + + + + ); +} + +function DocumentFormModal({ + entityId, + doc, + opened, + onClose, + onSaved, +}: { + entityId: string; + doc: Freight.GlExchangeDocument | null; + opened: boolean; + onClose: () => void; + onSaved: () => void; +}) { + const editing = doc != null; + const [title, setTitle] = useState(""); + const [visible, setVisible] = useState(false); + const [file, setFile] = useState(null); + // Re-seed the form whenever a different document (or "new") opens it. + const [seededFor, setSeededFor] = useState(null); + const seedKey = opened ? (doc?.id ?? "new") : null; + if (seedKey !== seededFor) { + setSeededFor(seedKey); + setTitle(doc?.title ?? ""); + setVisible(doc?.visibleToCustomer ?? false); + setFile(null); + } + + const save = useMutation({ + mutationFn: () => + editing + ? glExchangeService.update(doc.id, { + title: title.trim(), + visibleToCustomer: visible, + file, + }) + : glExchangeService.upload(entityId, { + title: title.trim(), + visibleToCustomer: visible, + file: file!, + }), + onSuccess: () => { + toast.success(editing ? "Document updated" : "Document shared"); + onSaved(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Could not save document"), + }); + + return ( + + + {editing ? "Edit shared document" : "Share a document"} + + } + radius="md" + size="md" + > + + setTitle(e.currentTarget.value)} + maxLength={300} + required + /> + + + + setVisible(e.currentTarget.checked)} + color="edr-green" + label="Visible to the customer" + description="Shows in the customer's booking documents. Off keeps it between the two GL desks." + /> + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index e79a4eb28..5583e6662 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -27,6 +27,7 @@ import { FileText, MessageSquareWarning, PackageCheck, + PackageOpen, Receipt, ShieldAlert, Ship, @@ -61,7 +62,8 @@ export type ClearanceViewLike = Pick< | "nextAction" | "dutyRequired" | "dutyAdvice" - | "dutyDispute" + | "draftDeclaration" + | "draftDeclarationChangeRequest" | "transitAssignee" | "roHold" | "roHoldReason" @@ -77,6 +79,7 @@ export type ClearanceViewLike = Pick< | "t1Closed" | "t1ClosedAt" | "offloaded" + | "offload" | "finalInvoice" | "vesselDepartureDate" | "vesselArrivalDate" @@ -113,32 +116,49 @@ function computeImportActiveStep( bookingMilestones: MilestoneRow[], t1Uploaded: boolean, freightPaid: boolean, + isBooking: boolean, ): number { if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; - if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1; + if (!clearance.transitAssignee?.name) return 1; + // Draft declaration is a booking-only step — the customer only ever reviews + // it on the booking-scoped portal page, so it never applies (and never + // gates) on the contract-scoped pre-booking page. Also a backward-compat + // guard: a booking that already has a real declaration filed got there + // before this step existed — never send it backward for a draft it was + // never asked to send. + if ( + isBooking && + !isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") && + !isMilestoneDone(clearance.milestones, "DECLARED") + ) { + return 2; + } + if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 3; if ( clearance.dutyRequired === null || clearance.dutyRequired === undefined || (clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED")) ) { - return 2; + return 4; } if ( clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID") ) { - return 3; + return 5; } - if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4; - if (!clearance.preClearanceFinalized) return 5; - if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6; - if (!bookingCreated) return 7; + if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 6; + if (!clearance.preClearanceFinalized) return 7; + if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 8; + if (!bookingCreated) return 9; // The customer pays the train/freight charges on the booking. Until that // settles the gate pass is not granted for this booking, so the flow stops here. - if (!freightPaid) return 8; - if (!clearance.gatepassGranted) return 9; - if (!t1Uploaded && !clearance.t1?.closed) return 10; - if (!clearance.t1?.closed) return 11; + if (!freightPaid) return 10; + if (!clearance.gatepassGranted) return 11; + // Step 12 is the read-only Offload step — cargo comes off the train at + // arrival, i.e. AFTER the T1 steps below, so it never gates the flow. + if (!t1Uploaded && !clearance.t1?.closed) return 13; + if (!clearance.t1?.closed) return 14; // Risk is "assigned" when the booking milestone says so OR the clearance view // already carries a riskLevel. The ET page derives its bookingMilestones from a // separately-fetched booking id that can lag or mismatch the booking carrying @@ -146,15 +166,15 @@ function computeImportActiveStep( const riskAssigned = Boolean(clearance.riskLevel) || isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED"); - if (!riskAssigned) return 12; + if (!riskAssigned) return 15; // Additional duty round is optional — resolved once skipped or paid. const secondDutyResolved = clearance.secondDuty?.skipped || clearance.secondDuty?.paid || isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID"); - if (!secondDutyResolved) return 13; - if (!clearance.importReleaseGranted) return 14; - return 15; + if (!secondDutyResolved) return 16; + if (!clearance.importReleaseGranted) return 17; + return 18; } function t1FilesFromWorkflow( @@ -267,6 +287,7 @@ export function PhasedClearanceActionPanel({ const freightPaid = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") || Boolean(clearance.gatepassGranted); + const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded); const activeStep = useMemo( () => isImport @@ -276,6 +297,7 @@ export function PhasedClearanceActionPanel({ bookingMilestones, t1Uploaded, freightPaid, + isBooking, ) : 0, [ @@ -333,6 +355,76 @@ export function PhasedClearanceActionPanel({ /> + + ) : undefined + } + > + {showEt && canEt ? ( + + ) : ( + + )} + + + + ) : ( + + ) + } + > + {showEt && canEt && activeStep === 2 ? ( + + ) : ( + + {(clearance.draftDeclaration?.files ?? []).map((file, index) => ( + + ))} + + + )} + + - {/* Djibouti must name the transit officer first — the declaration - is filed against whoever handles the shipment there, and the - API refuses the upload until the name is in. */} {showEt && canEt && - !clearance.transitAssignee?.name && - !isMilestoneDone(clearance.milestones, "DECLARED") ? ( - - ) : showEt && - canEt && !clearance.bookingReady && - (activeStep >= 1 || + (activeStep >= 3 || isMilestoneDone(clearance.milestones, "DECLARED")) ? ( } > - {showEt && canEt && activeStep === 2 ? ( + {showEt && canEt && activeStep === 3 ? ( = 4 || + (activeStep >= 5 || isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? ( } > - {showEt && canEt && activeStep === 5 ? ( + {showEt && canEt && activeStep === 6 ? ( + {/* Read-only: offload is recorded by operations when the train + reaches the destination, which happens after the T1 steps — so + it never holds the active pointer, and its icon stays neutral + until it actually happens. */} + } + completedIcon={ + offloadDone ? : + } + > + + + `${n} ${word}${n === 1 ? "" : "s"}`; + const bits = [ + offload?.containers ? plural(offload.containers, "container") : null, + offload?.wagons ? plural(offload.wagons, "wagon") : null, + offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : null, + offload?.destination ?? null, + ].filter(Boolean); + return bits.length ? bits.join(" · ") : "Offloaded"; +} + +/** + * Offload stats for the booking, read-only. Recorded by the warehouse + * auto-unload that runs when the train reaches the booking's destination — + * nothing here is actioned from clearance. + */ +export function OffloadStep({ + clearance, +}: { + clearance: ClearanceViewLike; +}) { + const offload = clearance.offload ?? null; + const done = offload?.offloaded ?? Boolean(clearance.offloaded); + + if (!done) { + return ( + + ); + } + + const stats: Array<[string, string]> = [ + ["Containers", offload?.containers ? String(offload.containers) : "—"], + ["Wagons", offload?.wagons ? String(offload.wagons) : "—"], + [ + "Weight", + offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : "—", + ], + ["Destination", offload?.destination ?? "—"], + ["GRN", offload?.grnNumber ?? "—"], + ["Location", offload?.location ?? "—"], + ]; + + return ( + + + }> + Offloaded + + + {offload?.offloadedAt + ? new Date(offload.offloadedAt).toLocaleString() + : "Recorded on arrival"} + {offload?.inventoryStatus ? ` · ${offload.inventoryStatus}` : ""} + + + + {stats.map(([label, value]) => ( + + + {label} + + + {value} + + + ))} + + + ); +} + const RISK_LEVEL_COLOR: Record = { GREEN: "green", YELLOW: "yellow", @@ -1569,6 +1748,145 @@ export function DeclarationStep({ ); } +/** + * GL Ethiopia sends a draft customs declaration (estimated price + files) for + * the customer to review in the portal before the real declaration is filed. + * Booking-only — the customer only ever sees this on the booking-scoped page. + */ +function DraftDeclarationStep({ + bookingId, + clearance, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string; + clearance: ClearanceViewLike; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [files, setFiles] = useState([]); + const [price, setPrice] = useState( + clearance.draftDeclaration?.price ?? "", + ); + const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB"); + const [loading, setLoading] = useState(false); + + const changeRequest = clearance.draftDeclarationChangeRequest; + const existingFiles = clearance.draftDeclaration?.files ?? []; + const replaceMode = existingFiles.length > 0; + + return ( + + {/* The customer sent this draft back — their words drive the + correction, so they lead the step. */} + {changeRequest ? ( + } + title={ + changeRequest.rounds > 1 + ? `Customer requested a change (round ${changeRequest.rounds})` + : "Customer requested a change" + } + > + + + {changeRequest.note} + + + Raised {new Date(changeRequest.raisedAt).toLocaleString()} — send a + corrected draft below. + + + + ) : null} + + {existingFiles.length > 0 ? ( + + + Current draft + + {existingFiles.map((file, index) => ( + + ))} + + ) : null} + + + + + +