diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index df82299a8..a3c09df97 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -32,6 +32,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"; @@ -175,6 +176,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar 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/migrations/3050000000000-AddCbeBillPaymentMethod.ts b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts new file mode 100644 index 000000000..449f3cebe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-AddCbeBillPaymentMethod.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface { + name = "AddCbeBillPaymentMethod3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen + // per the local convention (see 2460000000000-AddCacBankPaymentMethod). + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2bdc6ee16..e40e966be 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -995,6 +995,7 @@ export class BillingService { ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId, status: In(OPEN_STATUSES) }, + relations: { company: true }, }); if (!invoice) { throw new NotFoundException( @@ -1023,6 +1024,10 @@ export class BillingService { method: opts.method ?? "TELEBIRR", platform: opts.platform, payerAccount: opts.payerAccount, + // CBE_BILL: payer identity + the invoice's own due date as the bill expiry + // (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4). + payerName: invoice.company?.name, + expiresAt: invoice.dueAt?.toISOString(), returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); @@ -1033,8 +1038,9 @@ export class BillingService { .update({ id: invoice.id }, { paymentId: result.intentId }); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); - // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { + // billing must not simulate it. Kept for local demos only. NEVER for CBE_BILL — + // its bill must stay open until CBE actually settles it via /cbe/payment. + if (!result.immediateSuccess && opts.method !== "CBE_BILL") { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", eventId: `demo-${result.intentId}`, @@ -1079,4 +1085,52 @@ export class BillingService { paidAt, }); } + + /** + * CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for + * the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId, + * i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting. + */ + async billQuery(referenceId: string): Promise<{ + stillPayable: boolean; + payerName?: string | null; + currentAmountMinor?: number | null; + currency?: string | null; + reason?: string | null; + }> { + const repo = this.dataSource.getRepository(Invoice); + const open = await repo.findOne({ + where: { sourceId: referenceId, status: In(OPEN_STATUSES) }, + relations: { company: true }, + order: { issuedAt: "DESC" }, + }); + + if (open) { + const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount)); + const expired = open.dueAt && open.dueAt.getTime() < Date.now(); + return { + stillPayable: balance > 0 && !expired, + payerName: open.company?.name ?? null, + currentAmountMinor: balance, + currency: open.currency, + reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID", + }; + } + + const latest = await repo.findOne({ + where: { sourceId: referenceId }, + relations: { company: true }, + order: { createdAt: "DESC" }, + }); + return { + stillPayable: false, + payerName: latest?.company?.name ?? null, + currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null, + currency: latest?.currency ?? null, + reason: + latest?.status === Freight.InvoiceStatus.Paid + ? "ALREADY_PAID" + : "CANCELLED", + }; + } } 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/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index d7c45bb3c..6db55b66d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -327,6 +327,8 @@ export class LastMileService { */ async arrivalTrucksForBooking(bookingId: string): Promise< Array<{ + /** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -359,6 +361,7 @@ export class LastMileService { : []; const out: Array<{ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -386,6 +389,7 @@ export class LastMileService { } } out.push({ + lastMileId: lm.id, vehicleId: vehicle.id, truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, trailerPlateNumber: vehicle.trailerPlateNo || null, diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 5c4a4f7f7..ce072beca 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -4,7 +4,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ type PaymentType = string -type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" type Currency = "ETB" | "USD" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @@ -22,7 +22,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" }) referenceType?: string; - @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) method!: PaymentMethod @Column({ type: "enum", enum: ["ETB", "USD"] }) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0fc5a6ba5..b5ff51c48 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,30 +1,42 @@ import { Body, Controller, + forwardRef, HttpCode, HttpStatus, + Inject, Logger, Post, + UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { Public } from "@edr/api-common"; -import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; +import { + PaymentEventDto, + MarkPaidResponseDto, + BillQueryRequestDto, + BillQueryResponseDto, +} from "./internal-payment.dto"; import { PaymentService } from "./payment.service"; +import { BillingService } from "../billing/billing.service"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; /** - * Consumer side of the payment microservice's outbox relay. - * WARNING: currently unauthenticated — anyone who can reach the API can mark - * payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network. + * Consumer side of the payment microservice's outbox relay. Only the payment service may + * call this (shared service token — restored per docs/cbe/CBE_IMPLEMENTATION_PLAN.md R8). * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") -@Public() +@UseGuards(ServiceAuthGuard) @Controller("internal/payments") export class InternalPaymentController { private readonly logger = new Logger(InternalPaymentController.name); - constructor(private readonly paymentService: PaymentService) { } + constructor( + private readonly paymentService: PaymentService, + @Inject(forwardRef(() => BillingService)) + private readonly billingService: BillingService, + ) { } @Post("mark-paid") @HttpCode(HttpStatus.OK) @@ -36,4 +48,16 @@ export class InternalPaymentController { this.logger.log(`Marking payment ${event} as PAID`); return this.paymentService.handlePaymentEvent(event); } + + @Post("bill-query") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", + }) + async billQuery( + @Body() request: BillQueryRequestDto, + ): Promise { + return this.billingService.billQuery(request.referenceId); + } } diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index 1bf8c3f82..fd022ebc1 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -51,3 +51,24 @@ export class MarkPaidResponseDto { @ApiPropertyOptional() alreadyFinalized?: boolean; @ApiPropertyOptional() reason?: string; } + +/** + * CBE bill-query hop (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): the payment service asks + * "is this invoice still payable, by whom, for how much" while a CBE channel is on the line. + */ +export class BillQueryRequestDto { + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: PaymentReferenceType; + + @ApiProperty() @IsString() referenceId!: string; +} + +export class BillQueryResponseDto { + @ApiProperty() stillPayable!: boolean; + @ApiPropertyOptional() payerName?: string | null; + @ApiPropertyOptional() currentAmountMinor?: number | null; + @ApiPropertyOptional() currency?: string | null; + /** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */ + @ApiPropertyOptional() reason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d96bcf492..efc4ac926 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -51,6 +51,10 @@ export interface InitiateIntentInput { payerAccount?: string; returnUrl?: string; failureUrl?: string; + /** CBE_BILL: payer full name snapshot (feeds CBE's mandatory Full_Name). */ + payerName?: string; + /** CBE_BILL: intent expiry, ISO-8601 — the invoice due date, never a session TTL. */ + expiresAt?: string; } export interface InitiateIntentResult { @@ -79,6 +83,7 @@ const PROVIDER_TO_METHOD: Record = { CARD: "card", DMONEY: "dmoney", CAC_BANK: "cac-bank", + CBE_BILL: "cbe-bill", }; /** @@ -190,8 +195,13 @@ export class PaymentService { */ async initiate(input: InitiateIntentInput): Promise { try { - - + const isCbeBill = input.method === ProviderMethod.CBE_BILL; + // CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). + if (isCbeBill && input.currency?.toUpperCase() !== "ETB") { + throw new BadRequestException( + "CBE bill payment is only available for ETB invoices", + ); + } const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, @@ -199,11 +209,15 @@ export class PaymentService { referenceId: input.referenceId, orderRef: input.orderRef, // amountMinor: input.amountMinor, - amountMinor:1, + // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was + // debited against the intent amount, so the 1-birr dev shortcut would break it. + amountMinor: isCbeBill ? input.amountMinor : 1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, payerAccount: input.payerAccount, + payerName: input.payerName, + expiresAt: input.expiresAt, returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", failureUrl: diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 3b86a940c..255da0ea2 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -60,8 +60,10 @@ export class RefundDto { } export class ClientActionDto { - @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) - type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; + @ApiProperty({ + enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP", "SHOW_BILL_REFERENCE"], + }) + type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP" | "SHOW_BILL_REFERENCE"; @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) url?: string; @@ -80,6 +82,17 @@ export class ClientActionDto { @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) message?: string; + + @ApiPropertyOptional({ + description: "Set when type=SHOW_BILL_REFERENCE (CBE bill payment)", + }) + billReference?: string; + + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" }) + instructions?: string; + + @ApiPropertyOptional({ description: "Set when type=SHOW_BILL_REFERENCE" }) + expiresAt?: string; } export class InitiateResponseDto { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 586ef2f7c..41c8023f0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; -import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay } from './batch-window.util'; +import { + TrainSchedulingService, + effectiveWindowConfig, +} from './train-scheduling.service'; +import { eatDay, listConfigBookingWindows } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, @@ -167,6 +170,13 @@ export type BookingAllocationStatus = | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { + /** + * 0-based booking-window cycle this booking entered the pool in (derived from + * `fullyExecutedAt` against the schedule's window cycles). Ranking compares + * bookings within a cycle only — an earlier cycle always boards before a later + * one regardless of score. Null while the contract is still pending. + */ + windowCycleNo: number | null; fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; @@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit { } } + const cycleOf = await this.windowCycleIndexer(s); const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { + windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null, id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment @@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit { // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill // must rank bulk bookings by their wagon-derived priority too. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); let armed = false; let preempted = false; @@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit { armed: boolean; changed: boolean; }> = []; + // The day group shares one booking window (route+day grouping), so any + // member's window grid stands for the pool's cycle derivation. + let cycleSchedule: TrainSchedule | null = null; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit { ); continue; } + cycleSchedule ??= schedule; const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -1877,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit { // BULK bookings only get their real (wagon-derived) priority score now, at // batch time — stamp it and re-rank before the fill consumes the pool. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority( + pool, + cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined, + ); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); @@ -3257,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit { } } - /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ - private resortPoolByPriority(pool: Booking[]): void { + /** + * Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based + * booking-window cycle it arrived in: the last window whose open is at/before + * the timestamp (a timestamp in the doc-review/payment gap belongs to the + * cycle that just closed). The cycle grid comes from the schedule's frozen + * window-rule snapshot — the exact windows the cycle engine runs. + */ + private async windowCycleIndexer( + schedule: TrainSchedule, + ): Promise<(ts: Date | null | undefined) => number> { + if (!schedule.scheduledDepartureDate) return () => 0; + let starts: number[]; + try { + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const cfg = effectiveWindowConfig(schedule, liveCfg); + const windows = listConfigBookingWindows( + schedule.direction, + schedule.scheduledDepartureDate, + { + ...cfg, + reopenGapMinutes: + schedule.ruleReopenDelayMinutes ?? + cfg.docReviewMinutes + cfg.paymentWindowMinutes, + }, + ); + starts = windows.map((w) => w.start.getTime()); + } catch (err) { + // A failed cycle derivation must never block the batch — fall back to one + // flat cycle (pure priority order, the old behaviour). + this.logger.warn( + `Window-cycle derivation failed for schedule ${schedule.id}: ` + + `${(err as Error).message}`, + ); + return () => 0; + } + return (ts) => { + if (!ts) return 0; + const ms = ts.getTime(); + let idx = 0; + for (let i = 0; i < starts.length; i += 1) { + if (ms >= starts[i]) idx = i; + } + return idx; + }; + } + + /** + * Rank the batch pool: government first, then WINDOW CYCLE (bookings compete + * only within the cycle they arrived in — an earlier cycle's booking always + * outranks a later cycle's, whatever the scores), then priority score, then + * oldest. `cycleOf` comes from {@link windowCycleIndexer}. + */ + private resortPoolByPriority( + pool: Booking[], + cycleOf: (ts: Date | null | undefined) => number = () => 0, + ): void { pool.sort( (a, b) => Number(b.isGovernment) - Number(a.isGovernment) || + cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) || Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || (a.fullyExecutedAt?.getTime() ?? Infinity) - (b.fullyExecutedAt?.getTime() ?? Infinity) || diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 418338257..599e1ed72 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2754,11 +2754,13 @@ export class TrainSchedulingService { allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, + booking: allocation.booking, loadType: allocation.loadType ?? null, allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, containerNumbers: (allocation.containerItems ?? []) .map((item) => item.containerNumber) .filter(Boolean), + containerItems: allocation.containerItems ?? [], })), })), operation: await this.getImportDjiboutiOperation(schedule.id), @@ -2839,6 +2841,7 @@ export class TrainSchedulingService { return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); @@ -2847,6 +2850,7 @@ export class TrainSchedulingService { return ` ${wagonCells} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} ${esc(sealNumbers)} @@ -2861,6 +2865,18 @@ export class TrainSchedulingService { 0, ); + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { + (wagon.allocations ?? []).forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = item.bookingContainer?.containerSize; + if (size?.includes('40')) count40ft++; + else if (size?.includes('20')) count20ft++; + }); + }); + }); + return ` @@ -2910,6 +2926,9 @@ export class TrainSchedulingService {
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
Total loaded weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
@@ -2928,6 +2947,7 @@ export class TrainSchedulingService { Tare Weight Load Capacity Cargo Type + Company Container No Chassis No Seal No @@ -3010,6 +3030,19 @@ export class TrainSchedulingService { 0, ); const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + loadList.wagons.forEach((wagon) => { + wagon.allocations.forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = item.bookingContainer?.containerSize; + if (size?.includes('40')) count40ft++; + else if (size?.includes('20')) count20ft++; + }); + }); + }); + const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} @@ -3025,13 +3058,17 @@ export class TrainSchedulingService { ]; } return wagon.allocations.map( - (allocation) => ` + (allocation) => { + const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} + ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} - `, + `; + }, ); }) .join(''); @@ -3096,6 +3133,9 @@ export class TrainSchedulingService {
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -3115,6 +3155,7 @@ export class TrainSchedulingService { Seq Wagon Booking + Company Load Container numbers Weight T 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/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index c0b3cc060..7cd47f203 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3679,6 +3679,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; }> > { const rows: Array<{ @@ -3696,6 +3697,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; delivered: boolean; + inspectionStatus: string | null; }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, @@ -3710,7 +3712,8 @@ export class WarehouseInventoryService { b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", - COALESCE(inv.status = 'DELIVERED', false) AS delivered + COALESCE(inv.status = 'DELIVERED', false) AS delivered, + inv.inspection_status AS "inspectionStatus" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -3762,6 +3765,7 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + inspectionStatus: r.inspectionStatus, handoverSigned, })); } diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 6b8529225..9f10c3e0e 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -164,20 +164,25 @@ export class EdrOrgSeeder { manager: EntityManager, applicationId: string, ) { - const permissionRepository = manager.getRepository(Permission); - - // Upsert by key so reruns are idempotent; applicationId ties every - // permission to the EDR Freight application (also backfills rows that - // were previously seeded without the relation). - await permissionRepository.upsert( - EDR_FREIGHT_PERMISSIONS.map((permission) => ({ - id: permission.id, - key: permission.key, - name: { ...permission.name }, - applicationId, - })), - { conflictPaths: { key: true } }, - ); + // iam.permissions has TWO unique columns (PK id, UQ key) but ON CONFLICT + // can only target one. Seeding a hand-minted id that some older/retired key + // already owns in an environment slips past ON CONFLICT (key) and dies on + // the PK. The key is the identity every consumer resolves by (positions + // seeder maps key -> id at runtime), so ids are left to the column default + // and never sent — no id can collide. + await manager + .createQueryBuilder() + .insert() + .into(Permission) + .values( + EDR_FREIGHT_PERMISSIONS.map((permission) => ({ + key: permission.key, + name: { ...permission.name }, + applicationId, + })), + ) + .orUpdate(["name", "application_id"], ["key"]) + .execute(); this.logger.log( `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts new file mode 100644 index 000000000..caab85c3e --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -0,0 +1,13 @@ +import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; + +describe('EDR_FREIGHT_PERMISSIONS', () => { + // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE + // statement — a duplicated key there is a Postgres 21000 at boot, not a + // silent no-op. + it('has no duplicate keys', () => { + const keys = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key); + const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))]; + + expect(duplicates).toEqual([]); + }); +}); 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..9190316cd 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]; @@ -108,8 +109,12 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'), ]; -// Existing per-slug view ids are kept as-is: position-type grants reference -// them by id, so re-minting would orphan those rows. +// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and +// lets the column default mint the uuid — so these are kept only as a record of +// which ids each environment already holds. A hand-picked id must still never +// be recycled from a retired key: `edr_freight_app:rule_engine:truck_types:manage` +// owned …001b, and reusing it for transit-agents crashed boot with a PK 23505 +// on every environment that still had the retired row. const RULE_ENGINE_VIEW_IDS: Record = { 'cargo-types': 'b2000001-0001-4000-8000-000000000001', 'container-types': 'b2000001-0001-4000-8000-000000000003', @@ -123,6 +128,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': 'b2000003-0001-4000-8000-000000000001', }; // CRUD replaces the retired coarse `:manage`. New ids live in a fresh block @@ -136,7 +142,7 @@ const ruleEngineCrudId = ( ): string => { const n = RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 + - RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + + RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + 1; // 1..36 return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`; }; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx new file mode 100644 index 000000000..1a77632ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; +import { Coins, Truck } from "lucide-react"; + +import { api } from "@/services/api"; +import { warehouseService } from "@/services/warehouse.service"; +import { lastMileService } from "@/services/last-mile.service"; +import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; +import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + +const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—"); + +function inspectionLabel(status: string | null | undefined): { text: string; color: string } { + if (!status) return { text: "Pending", color: "gray" }; + if (status === "PASSED") return { text: "Passed", color: "edr-green" }; + if (status === "FAILED") return { text: "Failed", color: "red" }; + return { text: status, color: "gray" }; +} + +interface TruckRow { + key: string; + plate: string; + driver: string | null; + truckType: string | null; + containers: string[]; + warehouseArrived: string | null; + warehouseDeparted: string | null; + destinationArrived: string | null; + returned: string | null; + detentionOpen: boolean; + detentionDays: number | null; + detentionAmount: number | null; + hasDetentionRule: boolean; + inspection: { text: string; color: string }; +} + +/** + * Every truck tied to a booking's last mile — EDR-dispatched or customer + * self-haul (a booking only ever uses one), each with its own warehouse-gate + * and destination-detention clocks, plus the booking's cargo-side cost totals + * (storage/demurrage/double handling — billed per row internally, always + * shown here as one booking-level total). Detention stays EDR-only; customer + * self-haul rows show "—" since EDR only bills detention on its own fleet. + */ +export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { + const [feeModalOpen, setFeeModalOpen] = useState(false); + const [detentionModalOpen, setDetentionModalOpen] = useState(false); + + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const inventoryItems = inventoryQuery.data ?? []; + const latestInventory = inventoryItems[0] ?? null; + + const edrTrucksQuery = useQuery({ + queryKey: ["booking-edr-trucks", bookingId], + queryFn: () => warehouseService.getLastMileTrucks(bookingId), + }); + const edrTrucks = edrTrucksQuery.data ?? []; + + const customerTrucksQuery = useQuery({ + queryKey: ["booking-customer-trucks", bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId), + enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0, + }); + const customerTrucks = customerTrucksQuery.data ?? []; + + const mode: "EDR" | "CUSTOMER" | "NONE" = + edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE"; + + const containerItemsQuery = useQuery({ + queryKey: ["booking-container-items-for-trucks", bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId), + }); + const inspectionByContainer = new Map( + (containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]), + ); + + const lastMileId = edrTrucks[0]?.lastMileId ?? null; + + const detentionPreviewQuery = useQuery({ + queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + const detentionPreview = detentionPreviewQuery.data; + const detentionByVehicle = new Map( + (detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), + ); + + const lastMileRecordQuery = useQuery({ + queryKey: ["last-mile-record-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + + // Booking-level cost strip: same per-row fee preview the accrual dashboard + // and FeePreviewModal already use, summed across every inventory row on + // this booking rather than duplicated per row. + const feeQueries = useQueries({ + queries: inventoryItems.map((item) => + api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }), + ), + }); + const allFees = feeQueries.flatMap((q) => q.data ?? []); + const feeCurrency = allFees[0]?.currency ?? "USD"; + const sumByType = (type: string) => + allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); + + const rows: TruckRow[] = useMemo(() => { + if (mode === "EDR") { + return edrTrucks.map((t) => { + const g = detentionByVehicle.get(t.vehicleId); + return { + key: t.vehicleId, + plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", + driver: t.driverName, + truckType: t.truckType, + containers: t.containerNumber ? [t.containerNumber] : [], + warehouseArrived: t.arrivedAt, + warehouseDeparted: t.departedAt, + destinationArrived: g?.startDate ?? null, + returned: g?.endIsOpen ? null : g?.endDate ?? null, + detentionOpen: Boolean(g?.endIsOpen), + detentionDays: g?.chargeableDays ?? null, + detentionAmount: g?.amount ?? null, + hasDetentionRule: Boolean(g?.ruleId), + inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined), + }; + }); + } + if (mode === "CUSTOMER") { + return customerTrucks.map((t) => { + const containers = (t.containers ?? []).map((c) => c.containerNumber); + const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null)); + const inspection = + containers.length === 0 + ? inspectionLabel(undefined) + : statuses.size > 1 + ? { text: "Mixed", color: "yellow" } + : inspectionLabel([...statuses][0]); + return { + key: t.id, + plate: t.plateNumber, + driver: t.driverName, + truckType: t.truckType, + containers, + warehouseArrived: t.arrivedAt ?? null, + warehouseDeparted: t.departedAt ?? null, + destinationArrived: null, + returned: null, + detentionOpen: false, + detentionDays: null, + detentionAmount: null, + hasDetentionRule: false, + inspection, + }; + }); + } + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]); + + if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) { + return ( +
+ + + Loading trucks… + +
+ ); + } + + return ( + + setFeeModalOpen(true)}> + View breakdown + + ) + } + > + + + + + + + + setDetentionModalOpen(true)}> + Detention times + + ) + } + > + {rows.length === 0 ? ( + + No trucks assigned to this booking's last mile yet. + + ) : ( + + + + + Plate + Driver + Type + Container(s) + Wh. arrived + Wh. departed + Dest. arrived + Returned + Detention + Inspection + + + + {rows.map((r) => ( + + {r.plate} + {r.driver ?? "—"} + {r.truckType ?? "—"} + {r.containers.length ? r.containers.join(", ") : "—"} + {fmt(r.warehouseArrived)} + {fmt(r.warehouseDeparted)} + {fmt(r.destinationArrived)} + + {r.detentionOpen ? ( + + still out + + ) : ( + fmt(r.returned) + )} + + + {mode !== "EDR" || r.detentionDays == null ? ( + "—" + ) : ( + <> + {r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")} + {!r.hasDetentionRule && ( + + {" "} + · no rule + + )} + + )} + + + + {r.inspection.text} + + + + ))} + +
+
+ )} +
+ + setFeeModalOpen(false)} + inventoryId={latestInventory?.id ?? null} + /> + {mode === "EDR" && ( + setDetentionModalOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index f4a677991..ecbb0488e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -2,6 +2,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; +export * from "./BookingTrucksPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; 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/ContractMilestonesTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx index 435baaf68..1bf9b55e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx @@ -99,16 +99,20 @@ export function ContractMilestonesTimeline({ }); } + // Every acted approval step, not just hazardous ones — this is the one + // place the approval-time record shows up in the page's main content + // (the sidebar's ContractApprovalStepsCard has the same times, but only + // there, and only while the chain is still actionable). for (const step of contract.approvalSteps ?? []) { - if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue; if (!step.actedAt) continue; + const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION; items.push({ - key: `hazard-${step.id}`, + key: `step-${step.id}`, at: step.actedAt, - title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole, - detail: step.status === "REJECTED" ? "Rejected" : "Approved", - color: step.status === "REJECTED" ? "red" : "orange", - icon: Flame, + title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`, + detail: step.note ?? undefined, + color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green", + icon: hazard ? Flame : ShieldCheck, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx index c81758bae..068221400 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx @@ -1,9 +1,10 @@ import { Badge, Group } from "@mantine/core"; -import { Repeat } from "lucide-react"; +import { Building2, Repeat, UserRound } from "lucide-react"; import { CONTRACT_STATUS_COLOR, CONTRACT_STATUS_STYLES, + contractCourt, } from "@/features/contracts/contract-status.config"; interface ContractStatusBadgeProps { @@ -69,3 +70,42 @@ export function ContractStatusBadge({ ); } + +/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */ +export function ContractCourtBadge({ status }: { status: string }) { + const court = contractCourt(status); + if (!court) { + return ( + + — + + ); + } + const isCustomer = court === "customer"; + return ( + : + } + title={ + isCustomer + ? "Waiting on the customer to act" + : "Waiting on EDR staff to act" + } + style={{ + fontSize: "0.7rem", + letterSpacing: "0.05em", + display: "inline-flex", + whiteSpace: "nowrap", + }} + > + {isCustomer ? "With customer" : "With EDR"} + + ); +} 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..88074735b 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 === 4 ? ( = 4 || + (activeStep >= 6 || isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? ( } > - {showEt && canEt && activeStep === 5 ? ( + {showEt && canEt && activeStep === 7 ? ( + {/* 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} + + + + + + { + setDirectionFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 130 }} + aria-label="Filter by direction" + /> + { + setOwnershipFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Filter by ownership" + /> + { + setCreatedFrom(v ? new Date(v) : null); + resetPage(); + }} + maxDate={createdTo ?? undefined} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Created from" + /> + { + setCreatedTo(v ? new Date(v) : null); + resetPage(); + }} + minDate={createdFrom ?? undefined} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Created to" + /> + {showEmpty ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 68b3443c8..2d4489f22 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -352,6 +352,7 @@ export default function ContractClearanceDetailPage() { milestones={bookingMilestones} showOpsTabs={Boolean(linkedBookingId)} showWorkflowFilesTab={phasedCustoms} + exchangeEntityId={id} tradeDirection={contract?.tradeDirection ?? "IMPORT"} workflowFiles={workflowFiles} onViewFile={view} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 5ae66a51e..0caea8df5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -15,12 +15,14 @@ import { Files, Flame, History, + Info, LayoutGrid, Milestone, Package, Receipt, RefreshCw, Route as RouteIcon, + ShieldCheck, Snowflake, Users, } from "lucide-react"; @@ -35,6 +37,7 @@ import { Group, Loader, Paper, + SimpleGrid, Stack, Tabs, Text, @@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; -import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; +import { + ContractCourtBadge, + ContractStatusBadge, +} from "@/components/contracts/ContractStatusBadge"; import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper"; import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar"; import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; @@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() { status={contract.status} isRenewal={Boolean(contract.renewalOfId)} /> + {contract.contractKind === "GENERAL" ? "General" : "One-time"} @@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() { ) : ( + + + + + + {contract.equipmentReturn ? ( + + ) : null} + + {contract.contractValidityDays != null ? ( + + ) : null} + {contract.estimatedShipmentDate ? ( + + ) : null} + {contract.firstMilePickupAddress ? ( + + ) : null} + {contract.lastMileDeliveryAddress ? ( + + ) : null} + + {contract.financialTerms ? ( + + + Financial terms + + + {contract.financialTerms} + + + ) : null} + + + + + + {routes.length === 0 ? ( @@ -761,6 +862,25 @@ export default function ContractRequestDetailPage() { ); } +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + + {value} + +
+ ); +} + function MetaItem({ icon: Icon, text, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index bb2e14036..6bbee237b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell"; -import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; +import { + ContractCourtBadge, + ContractStatusBadge, +} from "@/components/contracts/ContractStatusBadge"; import { ContractStatusTabs, type ContractStatusTabKey, @@ -334,6 +337,19 @@ export default function ContractRequestsPage() { ), }, + { + id: "court", + size: COLUMN_WIDTH, + meta: COLUMN_META, + header: () => ( + Waiting on + ), + cell: ({ row }) => ( +
+ +
+ ), + }, { id: "approval", size: COLUMN_WIDTH, @@ -666,7 +682,7 @@ export default function ContractRequestsPage() { }} // table-fixed makes the per-column 120px widths stick; without // it auto-layout re-widens columns once cells wrap. - containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]" + containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]" footer={DataTableFooter} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index 22d3b3fca..a501cd50a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -20,6 +20,7 @@ import { AlertTriangle, ClipboardList, FileText, + Share2, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -33,6 +34,7 @@ import { PageHeader } from "@/components/page/PageHeader"; import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection"; import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; +import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; import { GlClearanceUploadModal, type GlClearanceUploadKind, @@ -228,6 +230,9 @@ export default function GlClearanceDetailPage() { > Customs documents (all steps)
+ }> + Document exchange + {incidentBookingId ? ( }> Incidents @@ -237,23 +242,20 @@ export default function GlClearanceDetailPage() { {/* GL Ethiopia cannot file the import customs declaration until this - desk names the officer handling the shipment in transit, so the - ask sits above everything else on the page. Exports have no such - gate — Djibouti's steps come after the declaration. */} - {isImport ? ( - - void refetch()} - /> - - ) : null} + desk names the officer handling the shipment in transit. Exports also + need transit assignment at the DJ stage after ET requests it. */} + + void refetch()} + /> + @@ -342,6 +344,10 @@ export default function GlClearanceDetailPage() { )} + + + + {incidentBookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index d3e4387b0..60044e550 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [ { value: "card", label: "Card" }, { value: "dmoney", label: "D-Money" }, { value: "cac-bank", label: "CAC Bank" }, + { value: "cbe-bill", label: "CBE Bill" }, ]; const STATUS_COLORS: Record = { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 0e8ac1367..6e816e772 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -10,6 +10,7 @@ export type ColumnFormat = | "boolean" | "activeBadge" | "rateStatus" + | "validityBadge" | "date" | "number" | "currency" @@ -483,6 +484,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "isActive", label: "Active", type: "boolean" }, ], }, + { + slug: "transit-agents", + label: "Transit Agents", + category: "configuration", + subtitle: + "Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window", + searchPlaceholder: "Search transit agents by name...", + cardTitleKey: "name", + columns: [ + { id: "name", header: "Name", accessorKey: "name" }, + { id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" }, + { id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" }, + { + id: "validityStatus", + header: "Validity", + accessorKey: "validityStatus", + format: "validityBadge", + }, + activeColumn, + ], + formFields: [ + { name: "name", label: "Name", type: "text", required: true }, + { name: "validFrom", label: "Valid from", type: "date", required: true }, + { + name: "validTo", + label: "Valid to", + type: "date", + required: true, + description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one", + }, + { name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" }, + ], + }, { slug: "yard-distances", label: "Yard Distances", diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index dc969dfc4..59b6e85bb 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -338,10 +338,10 @@ export const bookingsService = { note, }), - /** GL Djibouti names (or changes) that officer — unblocks the declaration. */ - assignTransitAssignee: (id: string, assignee: string) => + /** GL Djibouti picks (or changes) that officer — unblocks the declaration. */ + assignTransitAssignee: (id: string, transitAgentId: string) => postBooking(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), { - assignee, + transitAgentId, }), uploadDeclaration: async ( @@ -382,6 +382,22 @@ export const bookingsService = { return unwrap(response.data) as BookingDetail; }, + uploadDraftDeclaration: async ( + id: string, + files: File[], + price: number, + currency: string, + ): Promise => { + const form = new FormData(); + files.forEach((file, index) => form.append(`draft_declaration_${index}`, file)); + form.append("price", String(price)); + form.append("currency", currency); + const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return unwrap(response.data) as BookingDetail; + }, + finalizePreClearance: (id: string) => postBooking(B.CLEARANCE_FINALIZE_PRE(id)), diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index f5acfedeb..c020886fb 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -331,10 +331,10 @@ export const contractsService = { { note }, ), - /** GL Djibouti names (or changes) that officer — unblocks the declaration. */ - assignTransitAssignee: (id: string, assignee: string) => + /** GL Djibouti picks (or changes) that officer — unblocks the declaration. */ + assignTransitAssignee: (id: string, transitAgentId: string) => postContract(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), { - assignee, + transitAgentId, }), /** diff --git a/apps/edr-freight-web/backoffice/src/services/glExchange.service.ts b/apps/edr-freight-web/backoffice/src/services/glExchange.service.ts new file mode 100644 index 000000000..8973d805c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/glExchange.service.ts @@ -0,0 +1,60 @@ +import type { Freight } from "@edr/types"; + +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const X = URL_CONSTANTS.GL_EXCHANGE; + +export interface GlExchangeUpload { + title: string; + visibleToCustomer: boolean; + file: File; +} + +export interface GlExchangeEdit { + title?: string; + visibleToCustomer?: boolean; + /** Optional replacement bytes — omit to keep the stored file. */ + file?: File | null; +} + +const multipart = { headers: { "Content-Type": "multipart/form-data" } }; + +/** GL Ethiopia ↔ GL Djibouti shared documents for one booking or contract. */ +export const glExchangeService = { + list: async (entityId: string): Promise => { + const response = await client.get(X.FOR_ENTITY(entityId)); + return (unwrap(response.data) ?? []) as Freight.GlExchangeDocument[]; + }, + + upload: async ( + entityId: string, + input: GlExchangeUpload, + ): Promise => { + const form = new FormData(); + form.append("file", input.file); + form.append("title", input.title); + form.append("visibleToCustomer", String(input.visibleToCustomer)); + const response = await client.post(X.FOR_ENTITY(entityId), form, multipart); + return unwrap(response.data) as Freight.GlExchangeDocument; + }, + + update: async ( + documentId: string, + input: GlExchangeEdit, + ): Promise => { + const form = new FormData(); + if (input.file) form.append("file", input.file); + if (input.title != null) form.append("title", input.title); + if (input.visibleToCustomer != null) { + form.append("visibleToCustomer", String(input.visibleToCustomer)); + } + const response = await client.patch(X.DOCUMENT(documentId), form, multipart); + return unwrap(response.data) as Freight.GlExchangeDocument; + }, + + remove: async (documentId: string): Promise => { + await client.delete(X.DOCUMENT(documentId)); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/payments.service.ts b/apps/edr-freight-web/backoffice/src/services/payments.service.ts index bc4083f97..4615417ca 100644 --- a/apps/edr-freight-web/backoffice/src/services/payments.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/payments.service.ts @@ -19,7 +19,8 @@ export type PaymentMethod = | "waafi" | "card" | "dmoney" - | "cac-bank"; + | "cac-bank" + | "cbe-bill"; export interface PaymentRow { id: string; diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts index 3fcd6a7e8..682da4fcc 100644 --- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts @@ -94,6 +94,7 @@ const RESOURCE_BASE: Record = { "shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES, rates: URL_CONSTANTS.RULE_ENGINE.RATES, "approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES, + "transit-agents": URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS, }; const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => { diff --git a/apps/edr-freight-web/backoffice/src/services/transit-agents.service.ts b/apps/edr-freight-web/backoffice/src/services/transit-agents.service.ts new file mode 100644 index 000000000..bee743920 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/transit-agents.service.ts @@ -0,0 +1,20 @@ +import { api } from "../auth/http"; +import { URL_CONSTANTS } from "../constants/URLS"; + +export interface TransitAgent { + id: string; + name: string; + validFrom: string; + validTo: string; + isActive: boolean; +} + +export const transitAgentsService = { + /** Active + currently inside its validity window — the assignment dropdown. */ + async listAssignable() { + const response = await api.get( + URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE, + ); + return response.data; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index f32195b69..858526a4f 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -91,6 +91,7 @@ export interface ContainerItem { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; } /** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */ @@ -136,6 +137,8 @@ const cleanParams = (params: object) => /** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */ export interface LastMileArrivalTruck { + /** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts index bed483a90..5d886043a 100644 --- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts +++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts @@ -10,7 +10,8 @@ export type RuleEngineResourceSlug = | "yard-distances" | "shipping-lines" | "rates" - | "approval-rules"; + | "approval-rules" + | "transit-agents"; /** * Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 86b597c71..46e4b6c15 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -411,6 +411,12 @@ export type BookingAllocationStatus = | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { + /** + * 0-based booking-window cycle the booking entered the pool in. Ranking is + * per-cycle: an earlier cycle always boards before a later one regardless of + * priority score. Null while the contract is still pending. + */ + windowCycleNo: number | null; fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 3e0ad34cb..c6544c072 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -168,6 +168,8 @@ export const URL_CONSTANTS = { `/api/contracts/bookings/${bookingId}/milestones`, BOOKING_DUTY_SLIP: (bookingId: string) => `/api/contracts/bookings/${bookingId}/duty-slip`, + BOOKING_FINAL_INVOICE_APPROVE: (bookingId: string) => + `/api/contracts/bookings/${bookingId}/final-invoice/approve`, BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) => `/api/contracts/bookings/${bookingId}/final-invoice-slip`, BOOKING_SECOND_DUTY_SLIP: (bookingId: string) => diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 8c0bbb884..72475c723 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -9,6 +9,7 @@ import { Divider, Group, Loader, + Modal, Paper, SimpleGrid, Stack, @@ -72,6 +73,12 @@ export default function InvoiceDetailPage() { } = useQuery(api.invoices.get.queryOptions({ input: { id } })); const [payModalOpen, setPayModalOpen] = useState(false); + // CBE bill payment: the bill reference to pay at any CBE channel (no redirect). + const [billAction, setBillAction] = useState<{ + billReference?: string; + instructions?: string; + expiresAt?: string; + } | null>(null); // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // one of the signed-in customer's own invoices (unlike the admin-facing @@ -80,6 +87,11 @@ export default function InvoiceDetailPage() { mutationFn: (method: PaymentMethod) => api.invoices.pay.call({ id, payload: { method, platform: "web" } }), onSuccess: (data, method) => { + if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") { + setPayModalOpen(false); + setBillAction(data.clientAction); + return; + } const redirectUrl = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url @@ -393,6 +405,62 @@ export default function InvoiceDetailPage() { } onConfirm={(method) => payMutation.mutate(method)} /> + + {/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */} + setBillAction(null)} + centered + radius={18} + size={440} + title={Pay at CBE} + > + + + {billAction?.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} + + + + {billAction?.billReference} + + + + + Amount due:{" "} + + {formatCurrency(amountDue, invoice.currency)} + + + {billAction?.expiresAt && ( + + Pay before:{" "} + + {fmtDate(billAction.expiresAt)} + + + )} + + The invoice updates automatically once CBE confirms your payment. + + + ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx index 1589d250b..6e8546dd8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingClearanceWorkflowBanner.tsx @@ -14,6 +14,7 @@ import { } from "@mantine/core"; import { AlertTriangle, + Check, Download, Eye, FileBadge, @@ -86,9 +87,11 @@ export function BookingClearanceWorkflowBanner({ clearance.dutyRequired && clearance.dutyAdvice && !dutyPaid; - // A dispute clears the advice while it's open — show the "waiting on GL" - // state instead of the pay panel until GL re-advises. - const dutyDisputePending = Boolean(clearance.dutyDispute); + // A change request clears the draft while it's open — show the "waiting on + // GL" state instead of the review panel until GL sends a corrected draft. + const draftDeclarationChangeRequestPending = Boolean( + clearance.draftDeclarationChangeRequest, + ); return ( @@ -114,9 +117,20 @@ export function BookingClearanceWorkflowBanner({ ) : null} - {dutyDisputePending && clearance.dutyDispute ? ( - - ) : dutyPending && clearance.dutyAdvice ? ( + {draftDeclarationChangeRequestPending && clearance.draftDeclarationChangeRequest ? ( + + ) : clearance.draftDeclaration && !clearance.draftDeclaration.accepted ? ( + view(f)} + onChanged={() => void refetch()} + /> + ) : null} + + {dutyPending && clearance.dutyAdvice ? ( (null); const [loading, setLoading] = useState(false); - const [disputing, setDisputing] = useState(false); - const [note, setNote] = useState(""); - const [submittingDispute, setSubmittingDispute] = useState(false); const noticeFile = dutyAdvice.noticeFile; return ( @@ -250,11 +261,68 @@ function DutyAdvicePanel({ Submit payment slip - {disputing ? ( + + + ); +} + +/** + * GL Ethiopia sent a draft customs declaration — an estimated price + files + * the customer must accept before the real declaration is filed, or send back + * with a note asking for a change. + */ +function DraftDeclarationPanel({ + draftDeclaration, + bookingId, + onView, + onChanged, +}: { + draftDeclaration: NonNullable; + bookingId: string; + onView: (file: { name: string; url: string }) => void; + onChanged: () => void; +}) { + const [accepting, setAccepting] = useState(false); + const [requestingChange, setRequestingChange] = useState(false); + const [note, setNote] = useState(""); + const [submitting, setSubmitting] = useState(false); + + return ( + + + + + Estimated price:{" "} + + {draftDeclaration.price.toLocaleString()} {draftDeclaration.currency} + + + + {draftDeclaration.files.map((file, index) => ( + onView({ name: file.name, url: file.url })} + size="sm" + > + + + Draft declaration document {index + 1} ({file.name}) + + + ))} + + + Review the draft above. Accept it to let GL Ethiopia proceed with the + real customs declaration, or request a change if something is wrong. + + + {requestingChange ? (