finalize gl flow for import

This commit is contained in:
Marshal
2026-07-02 18:15:16 +00:00
parent dd47068a4b
commit 8d056c5014
17 changed files with 1084 additions and 11 deletions

View File

@@ -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);
}

View File

@@ -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<Booking> {
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);

View File

@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
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 },

View File

@@ -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<ReturnType<FilesService['findByResource']>> = [];
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<Contract> {
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);

View File

@@ -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())

View File

@@ -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,

View File

@@ -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}
) : (
<DateInput
label="Vessel departure date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"
clearable
/>
)}
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}

View File

@@ -4,8 +4,10 @@ import {
Badge,
Button,
Group,
Modal,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Stepper,
@@ -13,6 +15,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import {
TransitPermitMultiUpload,
@@ -25,6 +28,7 @@ import {
FileText,
PackageCheck,
Receipt,
ShieldAlert,
Ship,
Truck,
Upload,
@@ -68,6 +72,10 @@ export type ClearanceViewLike = Pick<
| "finalInvoice"
| "vesselDepartureDate"
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
export type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[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({
)}
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="GL Djibouti grants after wagon allocation"
icon={
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportGatepassStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showDj && canDj}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
@@ -525,6 +566,64 @@ export function PhasedClearanceActionPanel({
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
icon={
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
<CheckCircle2 size={14} />
) : (
<ShieldAlert size={14} />
)
}
>
<RiskStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Additional duty & tax"
description="GL Ethiopia advises if more duty applies"
icon={<Receipt size={14} />}
>
<SecondDutyStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Import release"
description="GL Ethiopia uploads the release document"
icon={
clearance.importReleaseGranted ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
<ImportReleaseStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
@@ -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<Date | null>(new Date());
const [loading, setLoading] = useState(false);
if (clearance.gatepassGranted) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`Gate pass granted${
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
}`}
/>
);
}
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
return (
<Stack gap="sm">
<StepStatus
done={false}
pendingLabel={
wagonAllocated
? "Wagons allocated — GL Djibouti can grant the gate pass."
: "Available once wagons are allocated."
}
doneLabel=""
/>
{canAct && bookingId ? (
<>
<Button
color="edr-green"
leftSection={<Truck size={16} />}
disabled={!wagonAllocated}
onClick={() => {
setAt(new Date());
setOpened(true);
}}
>
Grant gate pass
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Grant gate pass</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<DateTimePicker
label="Gate pass time"
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
Cancel
</Button>
<Button
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.grantGatepass(
bookingId,
(at ?? new Date()).toISOString(),
);
toast.success("Gate pass granted");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Grant
</Button>
</Group>
</Stack>
</Modal>
</>
) : null}
</Stack>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
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<string>("GREEN");
const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) {
return (
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
variant="filled"
radius="sm"
>
{clearance.riskLevel ?? "Assigned"}
</Badge>
<Text size="sm" c="dimmed">
Customs risk assigned
{clearance.riskAssignedAt
? ` · ${new Date(clearance.riskAssignedAt).toLocaleString()}`
: ""}
. The customer can see this level.
</Text>
</Group>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Ethiopia to assign the customs risk level."
doneLabel=""
/>
);
}
return (
<Stack gap="sm">
<SegmentedControl
fullWidth
value={level}
onChange={setLevel}
data={[
{ label: "Green", value: "GREEN" },
{ label: "Yellow", value: "YELLOW" },
{ label: "Red", value: "RED" },
]}
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
The customer sees the assigned risk level.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success("Customs risk assigned");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Assign risk
</Button>
</Group>
</Stack>
);
}
/** 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<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const [attachment, setAttachment] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const duty = clearance.secondDuty ?? null;
if (duty?.skipped) {
return (
<StepStatus done pendingLabel="" doneLabel="No additional duty or tax applies." />
);
}
if (duty?.advised) {
return (
<Stack gap="sm">
<Text size="sm">
Additional duty advised:{" "}
<strong>
{duty.amount?.toLocaleString()} {duty.currency}
</strong>
{duty.declarationSerial ? ` · ${duty.declarationSerial}` : ""}
</Text>
{duty.noticeFile ? (
<PhasedUploadedFileRow
label="Additional Duty / Tax Notice"
file={duty.noticeFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{duty.slipFile ? (
<PhasedUploadedFileRow
label="Customer payment slip"
file={duty.slipFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
<StepStatus
done={duty.paid}
pendingLabel="Waiting for the customer to pay and attach the slip in the portal."
doneLabel="Additional duty paid — slip received."
/>
</Stack>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus
done={false}
pendingLabel="GL Ethiopia decides whether additional duty/tax applies."
doneLabel=""
/>
);
}
return (
<Stack gap="md">
{!riskAssigned ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Usually decided after the customs risk is assigned.
</Alert>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Switch
label="Additional duty/tax applies"
description="Turn off if no further duty or tax is due after arrival."
checked={dutyRequired}
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
/>
{dutyRequired ? (
<>
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<TextInput
label="Declaration / payment code"
placeholder="Customs payment reference"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<PhasedFileDropzone
label="Duty notice attachment"
description="Any file type — shown to the customer in the portal."
accept="*/*"
value={attachment}
onChange={setAttachment}
onPreview={onViewFile}
/>
</>
) : null}
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={dutyRequired && (amount === "" || Number(amount) <= 0 || !attachment)}
fullWidth
onClick={async () => {
setLoading(true);
try {
await contractsService.adviseSecondDuty(bookingId, {
dutyRequired,
amount: dutyRequired ? Number(amount) : undefined,
currency,
declarationSerial: serial || undefined,
attachment: dutyRequired ? attachment : null,
});
toast.success(
dutyRequired ? "Additional duty advised to customer" : "No additional duty recorded",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
{dutyRequired ? "Send to customer" : "No additional duty"}
</Button>
</Stack>
);
}
/** GL ET uploads the import release document (auto-completes IMPORT_RELEASE_GRANTED). */
function ImportReleaseStep({
bookingId,
clearance,
canAct,
workflowFiles = [],
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const releaseFile = findWorkflowFile(workflowFiles, "import_release");
return (
<Stack gap="sm">
{releaseFile ? (
<PhasedUploadedFileRow
label="Import Release"
file={releaseFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{clearance.importReleaseGranted ? (
<StepStatus done pendingLabel="" doneLabel="Import release granted." />
) : canAct && bookingId ? (
<>
<PhasedFileDropzone
label="Import release document"
description="Any file type."
accept="*/*"
value={file}
onChange={setFile}
replaceMode={Boolean(releaseFile)}
onPreview={onViewFile}
/>
<Button
color="edr-green"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadGlDocuments(bookingId, {
import_release: file,
});
setFile(null);
toast.success("Import release uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{releaseFile ? "Replace import release" : "Upload import release"}
</Button>
</>
) : (
<StepStatus
done={false}
pendingLabel="Waiting for GL Ethiopia to upload the import release document."
doneLabel=""
/>
)}
</Stack>
);
}
export function StepStatus({
done,
pendingLabel,

View File

@@ -224,6 +224,8 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/final-invoice`,
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
`/contracts/bookings/${bookingId}/final-invoice/confirm`,
BOOKING_SECOND_DUTY: (bookingId: string) =>
`/contracts/bookings/${bookingId}/second-duty`,
BOOKING_INCIDENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/incidents`,
},

View File

@@ -362,9 +362,14 @@ export const bookingsService = {
return unwrap(response.data) as BookingDetail;
},
uploadDeliveryOrder: async (id: string, file: File): Promise<BookingDetail> => {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});

View File

@@ -283,9 +283,11 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
@@ -407,6 +409,30 @@ export const contractsService = {
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
},
/** GL ET advises (or skips) the post-arrival additional duty/tax round (import). */
adviseSecondDuty: async (
bookingId: string,
payload: {
dutyRequired: boolean;
amount?: number;
currency?: string;
declarationSerial?: string;
attachment?: File | null;
},
): Promise<{ advised: boolean; skipped: boolean }> => {
const form = new FormData();
form.append("dutyRequired", String(payload.dutyRequired));
if (payload.amount != null) form.append("amount", String(payload.amount));
if (payload.currency) form.append("currency", payload.currency);
if (payload.declarationSerial)
form.append("declarationSerial", payload.declarationSerial);
if (payload.attachment) form.append("attachment", payload.attachment);
const response = await client.post(C.BOOKING_SECOND_DUTY(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as { advised: boolean; skipped: boolean };
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(

View File

@@ -133,6 +133,8 @@ export const URL_CONSTANTS = {
`/api/contracts/bookings/${bookingId}/duty-slip`,
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/second-duty-slip`,
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
BOOKING_REQUESTS: (id: string) => `/api/contracts/${id}/booking-requests`,
BOOKING_REQUEST_CANCEL: (reqId: string) =>

View File

@@ -584,6 +584,42 @@ export default function ContractDetailPage() {
<ContractClearanceWorkflowBanner contract={contract} />
) : null}
{clearanceView?.riskLevel ? (
<Paper
withBorder
radius="lg"
p="md"
style={{ borderColor: BORDER, background: "#FBFDFC" }}
>
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
Customs risk level
</Text>
<Badge
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
variant="filled"
radius="sm"
>
{clearanceView.riskLevel}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
</Text>
) : null}
</Group>
</Paper>
) : null}
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
<SecondDutyDueCard
duty={clearanceView.secondDuty}
bookingId={clearanceView.linkedBookingId}
onView={view}
onChanged={() => void refetchClearance()}
/>
) : null}
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
<FinalInvoiceDueCard
invoice={clearanceView.finalInvoice}
@@ -1605,3 +1641,145 @@ function FinalInvoiceDueCard({
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -333,4 +333,19 @@ export const contractsService = {
);
return data.data ?? data;
},
/** Customer attaches the slip for the post-arrival additional duty round (import). */
uploadSecondDutySlip: async (
bookingId: string,
file: File,
): Promise<{ milestoneCompleted: boolean }> => {
const form = new FormData();
form.append("file", file);
const { data } = await client.post(
C.BOOKING_SECOND_DUTY_SLIP(bookingId),
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data ?? data;
},
};

View File

@@ -23,6 +23,27 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[
{ code: "ex8", label: "EX8 Declaration", uploadedBy: "gl_et", category: "declaration", tradeDirection: "EXPORT" },
{ code: "duty_tax_notice", label: "Duty / Tax Notice", uploadedBy: "gl_et", category: "duty" },
{ code: "duty_tax_receipt", label: "Duty / Tax Payment Slip", uploadedBy: "customer", category: "duty" },
{
code: "duty_tax_notice_2",
label: "Additional Duty / Tax Notice",
uploadedBy: "gl_et",
category: "duty",
tradeDirection: "IMPORT",
},
{
code: "duty_tax_receipt_2",
label: "Additional Duty / Tax Payment Slip",
uploadedBy: "customer",
category: "duty",
tradeDirection: "IMPORT",
},
{
code: "import_release",
label: "Import Release",
uploadedBy: "gl_et",
category: "declaration",
tradeDirection: "IMPORT",
},
{ code: "transit_permitted", label: "Transit Permit", uploadedBy: "gl_et", category: "transit", tradeDirection: "IMPORT" },
{
code: "export_transport_document",

View File

@@ -282,6 +282,22 @@ export interface ClearanceFinalInvoiceSummary {
confirmedAt: string | null;
}
/**
* Post-arrival second duty/tax round (import): GL ET advises an additional
* amount with a notice; the customer attaches another payment slip. Optional —
* GL ET may mark it skipped when no further duty applies.
*/
export interface ClearanceSecondDuty {
advised: boolean;
skipped: boolean;
amount: number | null;
currency: string | null;
declarationSerial?: string | null;
noticeFile: { id: string; name: string; url: string } | null;
slipFile: { id: string; name: string; url: string } | null;
paid: boolean;
}
/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */
export interface DjClearanceScheduleBooking {
bookingId: string;
@@ -359,6 +375,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;
}
export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS";
@@ -456,6 +478,8 @@ export const IMPORT_MILESTONES = [
"OFFLOADED",
"T1_CLOSED",
"RISK_ASSIGNED",
"SECOND_DUTY_ADVISED",
"SECOND_DUTY_PAID",
"IMPORT_RELEASE_GRANTED",
"IMPORT_PROCESS_COMPLETED",
"STORAGE_INVOICE_RAISED",

View File

@@ -566,6 +566,12 @@ export interface ClearanceView {
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: import("./contracts").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?: import("./contracts").ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
}
/** Company an invoice is billed to (minimal projection). */