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 688c6ee18..c5574377a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -617,12 +617,14 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), + vesselDepartureDate, ); return this.transitionService.enrichBookingResponse(booking); } 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 45c96977e..3f169c49c 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 @@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, } from '@edr/types'; @@ -80,6 +81,12 @@ export interface BookingClearanceView { offloaded?: boolean; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -200,6 +207,8 @@ export class BookingClearanceService { milestones.find((m) => m.milestoneCode === code); const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState(milestones, files); return { bookingId, @@ -248,6 +257,17 @@ export class BookingClearanceService { : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -497,6 +517,7 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -515,6 +536,12 @@ export class BookingClearanceService { file, }); + if (vesselDepartureDate?.trim()) { + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate: vesselDepartureDate.trim(), + } as never); + } + if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForOperation(bookingId); 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 e9ad726fd..648d9666f 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 @@ -39,6 +39,16 @@ const IMPORT_DEFS: Record> = { OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false }, T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false }, RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false }, + SECOND_DUTY_ADVISED: { + label: 'Additional Duty and Taxes Advised', + ownerRegion: 'ET', + triggeredByDoc: false, + }, + SECOND_DUTY_PAID: { + label: 'Additional Duty and Tax Paid', + ownerRegion: 'CUST', + triggeredByDoc: true, + }, IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true }, IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true }, STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false }, 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 9f8a5af7c..73a4fe117 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 @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm import { ContractDocPhase, type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, } from '@edr/types'; @@ -94,6 +95,12 @@ export interface ContractClearanceView { offloaded?: boolean; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -226,8 +233,9 @@ export class ContractClearanceService { files, contract.tradeDirection ?? 'IMPORT', ); + let bookingFiles: Awaited> = []; if (cycle?.bookingId) { - const bookingFiles = await this.filesService.findByResource( + bookingFiles = await this.filesService.findByResource( cycle.bookingId, 'bookings', ); @@ -269,6 +277,11 @@ export class ContractClearanceService { bookingMilestones.find((m) => m.milestoneCode === code); const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState( + bookingMilestones, + bookingFiles, + ); let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { @@ -332,6 +345,17 @@ export class ContractClearanceService { : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -1091,6 +1115,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1111,6 +1136,11 @@ export class ContractClearanceService { }); const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle && vesselDepartureDate?.trim()) { + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate: vesselDepartureDate.trim(), + }); + } if (cycle?.preClearanceFinalizedAt) { await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForBooking(contractId); 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 6cfec95f3..6ded65ae9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -611,9 +611,15 @@ export class ContractsController { uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + return this.clearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); } @Post(':id/clearance/release-order') @@ -998,6 +1004,47 @@ export class ContractsController { ); } + @Post('bookings/:bookingId/second-duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', + }) + adviseSecondDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.adviseSecondDuty( + bookingId, + { + dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }, + attachment, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) + uploadSecondDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadSecondDutySlip(bookingId, file); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) 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 581257f18..8fed3a8ff 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 @@ -645,6 +645,129 @@ export class GlOperationsService { return summary; } + /** + * GL ET advises (or skips) the post-arrival additional duty/tax round (import). + * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. + */ + async adviseSecondDuty( + bookingId: string, + input: { + dutyRequired: boolean; + amount?: number; + currency?: string; + declarationSerial?: string; + }, + attachment?: Express.Multer.File, + userId?: string, + ): Promise<{ advised: boolean; skipped: boolean }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Additional duty applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException('Additional duty applies to import shipments only.'); + } + + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); + + if (!input.dutyRequired) { + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { advised: false, skipped: true }; + } + + if (!input.amount || input.amount <= 0) { + throw new BadRequestException('Duty amount must be greater than zero.'); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); + if (!attachment && !hasNotice) { + throw new BadRequestException('Attach the additional duty/tax notice.'); + } + if (attachment) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice_2', + file: attachment, + }); + } + + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'SECOND_DUTY_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency ?? 'ETB', + declarationSerial: input.declarationSerial, + }, + userId, + ); + return { advised: true, skipped: false }; + } + + /** Customer attaches the payment slip for the additional duty round. */ + async uploadSecondDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + if (advised?.status !== 'COMPLETED') { + throw new BadRequestException('No additional duty has been advised for this shipment.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt_2', + file, + }); + + await this.milestoneService.ensureForBooking( + bookingId, + 'SECOND_DUTY_PAID', + booking.tradeDirection ?? 'IMPORT', + ); + await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { milestoneCompleted: true }; + } + + /** Second duty round state for clearance views. */ + secondDutyState( + milestones: Array<{ + milestoneCode: string; + status: string; + metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; + }>, + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + ): Freight.ClearanceSecondDuty | null { + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); + if (!advised && !paid) return null; + + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + + return { + advised: advised?.status === 'COMPLETED', + skipped: advised?.status === 'SKIPPED', + amount: advised?.metadata?.dutyAmount ?? null, + currency: advised?.metadata?.dutyCurrency ?? null, + declarationSerial: advised?.metadata?.declarationSerial ?? null, + noticeFile: toRef('duty_tax_notice_2'), + slipFile: toRef('duty_tax_receipt_2'), + paid: paid?.status === 'COMPLETED', + }; + } + /** Final-invoice state joined with its document + slip files, for clearance views. */ async finalInvoiceSummary( bookingId: string, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index ba54e3b8b..07eae83da 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -62,10 +62,11 @@ export function GlClearanceUploadModal({ setLoading(true); try { if (isDo) { + const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined; if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file); + await bookingsService.uploadDeliveryOrder(entityId, file, iso); } else { - await contractsService.uploadDeliveryOrder(entityId, file); + await contractsService.uploadDeliveryOrder(entityId, file, iso); } toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); } else { @@ -117,7 +118,15 @@ export function GlClearanceUploadModal({ size="sm" required /> - ) : null} + ) : ( + setVesselDate(v ? new Date(v) : null)} + size="sm" + clearable + /> + )} & { operationReady?: boolean }; export type MilestoneRow = NonNullable[number]; @@ -91,6 +99,7 @@ export function isBookingMilestoneDone( function computeImportActiveStep( clearance: ClearanceViewLike, bookingCreated: boolean, + bookingMilestones: MilestoneRow[], ): number { if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1; @@ -111,8 +120,17 @@ function computeImportActiveStep( if (!clearance.preClearanceFinalized) return 5; if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6; if (!bookingCreated) return 7; - if (!clearance.t1?.closed) return 8; - return 9; + if (!clearance.gatepassGranted) return 8; + if (!clearance.t1?.closed) return 9; + if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10; + // 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 11; + if (!clearance.importReleaseGranted) return 12; + return 13; } function t1FilesFromWorkflow( @@ -133,7 +151,9 @@ function declarationFilesFromWorkflow( workflowFiles: Freight.ClearanceWorkflowFile[], ): Array<{ code: string; label: string; file: { id: string; name: string } }> { return workflowFiles - .filter((f) => f.category === "declaration" && f.file) + .filter( + (f) => f.category === "declaration" && f.code !== "import_release" && f.file, + ) .map((f) => ({ code: f.code, label: f.label, @@ -205,9 +225,15 @@ export function PhasedClearanceActionPanel({ // The server only builds the t1 block once a booking is linked — use it as the // booking-created signal on pages that don't pass bookingCreated (GL DJ detail). const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1); + // The booking that carries the post-booking steps (gate pass, risk, duty, release). + const actionBookingId = + clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null; const activeStep = useMemo( - () => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0), - [clearance, isImport, effectiveBookingCreated], + () => + isImport + ? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones) + : 0, + [clearance, isImport, effectiveBookingCreated, bookingMilestones], ); if (isImport) { @@ -508,6 +534,21 @@ export function PhasedClearanceActionPanel({ )} + : + } + > + + + + + + ) : ( + + ) + } + > + + + + } + > + + + + + ) : ( + + ) + } + > + + @@ -717,6 +816,453 @@ function ImportT1Section({ ); } +/** GL DJ grants the import gate pass once wagons are allocated (captures time). */ +function ImportGatepassStep({ + bookingId, + clearance, + canAct, + onChanged, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + onChanged?: () => void; +}) { + const [opened, setOpened] = useState(false); + const [at, setAt] = useState(new Date()); + const [loading, setLoading] = useState(false); + + if (clearance.gatepassGranted) { + return ( + + ); + } + + const wagonAllocated = Boolean(clearance.train?.wagonAllocated); + + return ( + + + {canAct && bookingId ? ( + <> + + setOpened(false)} + title={Grant gate pass} + radius="md" + size="sm" + > + + setAt(v ? new Date(v) : null)} + required + /> + + + + + + + + ) : null} + + ); +} + +const RISK_LEVEL_COLOR: Record = { + GREEN: "green", + YELLOW: "yellow", + RED: "red", +}; + +/** GL ET assigns the customs examination risk (visible to the customer). */ +function RiskStep({ + bookingId, + clearance, + canAct, + done, + onChanged, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + done: boolean; + onChanged?: () => void; +}) { + const [level, setLevel] = useState("GREEN"); + const [loading, setLoading] = useState(false); + + if (done || clearance.riskLevel) { + return ( + + + {clearance.riskLevel ?? "Assigned"} + + + Customs risk assigned + {clearance.riskAssignedAt + ? ` · ${new Date(clearance.riskAssignedAt).toLocaleString()}` + : ""} + . The customer can see this level. + + + ); + } + + if (!canAct || !bookingId) { + return ( + + ); + } + + return ( + + + + + The customer sees the assigned risk level. + + + + + ); +} + +/** Optional post-arrival additional duty/tax round (GL ET advises; customer pays slip). */ +function SecondDutyStep({ + bookingId, + clearance, + canAct, + riskAssigned, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + riskAssigned: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [dutyRequired, setDutyRequired] = useState(true); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [serial, setSerial] = useState(""); + const [attachment, setAttachment] = useState(null); + const [loading, setLoading] = useState(false); + + const duty = clearance.secondDuty ?? null; + + if (duty?.skipped) { + return ( + + ); + } + + if (duty?.advised) { + return ( + + + Additional duty advised:{" "} + + {duty.amount?.toLocaleString()} {duty.currency} + + {duty.declarationSerial ? ` · ${duty.declarationSerial}` : ""} + + {duty.noticeFile ? ( + + ) : null} + {duty.slipFile ? ( + + ) : null} + + + ); + } + + if (!canAct || !bookingId) { + return ( + + ); + } + + return ( + + {!riskAssigned ? ( + }> + Usually decided after the customs risk is assigned. + + ) : null} + + + setDutyRequired(e.currentTarget.checked)} + /> + {dutyRequired ? ( + <> + + +