refactor T1 document handling and update related logic for import/export processes

This commit is contained in:
Marshal
2026-07-05 22:58:02 +00:00
parent 58fa0d9184
commit 44fbb481b1
3 changed files with 191 additions and 76 deletions

View File

@@ -244,9 +244,10 @@ export class GlOperationsService {
}
/**
* T1 transit-document lifecycle state for an import shipment booking. Wagon
* allocation opens the upload window; train departure locks it; train arrival
* lets GL Ethiopia close (accept) the T1 set.
* T1 transit-document lifecycle state for an import shipment booking. The
* gate pass (secured on the train schedule after wagon allocation) opens the
* upload window; train departure locks it; train arrival lets GL Ethiopia
* close (accept) the T1 set.
*/
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
const train = await this.trainState(bookingId);
@@ -269,7 +270,8 @@ export class GlOperationsService {
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
* Replaces the previous batch; locked once the train departs or T1 is closed.
*/
async uploadT1Documents(
@@ -287,6 +289,12 @@ export class GlOperationsService {
'Wagons must be allocated before T1 transport documents can be uploaded.',
);
}
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
throw new BadRequestException(
'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
);
}
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
@@ -303,7 +311,8 @@ export class GlOperationsService {
/**
* Close (accept) the T1/transport document set.
* Import: GL Ethiopia closes once the train has arrived (T1 files required).
* Export: GL Djibouti closes after the gate pass (transport document required).
* Export: GL Djibouti closes once the train arrives at Djibouti (transport
* document required).
*/
async closeT1(
bookingId: string,
@@ -337,10 +346,9 @@ export class GlOperationsService {
'The transport document must be uploaded before T1 can be closed.',
);
}
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
if (!state.trainArrivedAt) {
throw new BadRequestException(
'Secure the Djibouti gate pass on the train schedule before closing T1.',
'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.',
);
}
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.

View File

@@ -53,8 +53,9 @@ import { bookingsService } from "@/services/bookings.service";
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ)
* → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm.
* → 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.
*/
export function computeExportActiveStep(
clearance: ClearanceViewLike,
@@ -77,8 +78,8 @@ export function computeExportActiveStep(
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.gatepassGranted) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
}
@@ -395,17 +396,9 @@ export function ExportClearanceStepper({
</Stack>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Accept T1"
description="GL Djibouti closes the transport document"
description="GL Djibouti closes once the train arrives"
icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
>
<AcceptT1Step
@@ -420,6 +413,14 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -554,6 +555,7 @@ function AcceptT1Step({
}) {
const [loading, setLoading] = useState(false);
const files = exportTransitFilesFromWorkflow(workflowFiles);
const arrived = Boolean(clearance.train?.arrivedAt);
return (
<Stack gap="sm">
@@ -582,9 +584,9 @@ function AcceptT1Step({
pendingLabel={
!transportIssued
? "Waiting for the transport document."
: clearance.gatepassGranted
? "Gate pass granted — GL Djibouti accepts (closes) the T1."
: "Available after the gate pass is granted."
: arrived
? "Train arrived — GL Djibouti accepts (closes) the T1."
: "Available once the train arrives at Djibouti."
}
doneLabel=""
/>
@@ -592,7 +594,7 @@ function AcceptT1Step({
<Button
color="edr-green"
loading={loading}
disabled={!transportIssued || !clearance.gatepassGranted}
disabled={!transportIssued || !arrived}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);

View File

@@ -98,6 +98,7 @@ function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
@@ -119,16 +120,17 @@ function computeImportActiveStep(
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!clearance.gatepassGranted) return 8;
if (!clearance.t1?.closed) return 9;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
if (!t1Uploaded && !clearance.t1?.closed) return 9;
if (!clearance.t1?.closed) return 10;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 11;
// 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;
if (!secondDutyResolved) return 12;
if (!clearance.importReleaseGranted) return 13;
return 14;
}
function t1FilesFromWorkflow(
@@ -226,12 +228,18 @@ export function PhasedClearanceActionPanel({
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
const actionBookingId =
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
const activeStep = useMemo(
() =>
isImport
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
? computeImportActiveStep(
clearance,
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
)
: 0,
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
);
if (isImport) {
@@ -544,22 +552,45 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
description="GL Djibouti uploads after the gate pass is secured"
icon={
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
t1Uploaded || clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<Truck size={14} />
)
}
>
<ImportT1Section
<ImportT1UploadStep
t1={clearance.t1 ?? null}
gatepassGranted={Boolean(clearance.gatepassGranted)}
workflowFiles={workflowFiles}
canDjAct={showDj && canDj}
canEtAct={showEt && canEt}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Close T1"
description="GL Ethiopia closes once the train arrives"
icon={
clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<PackageCheck size={14} />
)
}
>
<ImportT1CloseStep
t1={clearance.t1 ?? null}
t1Uploaded={t1Uploaded}
canEtAct={showEt && canEt}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
@@ -645,26 +676,29 @@ export function PhasedClearanceActionPanel({
);
}
function ImportT1Section({
/**
* GL Djibouti uploads the T1 transport documents (multi-file) once the gate
* pass is secured on the train schedule; locked on departure or T1 close.
*/
function ImportT1UploadStep({
t1,
gatepassGranted,
workflowFiles = [],
canDjAct,
canEtAct,
onChanged,
onViewFile,
onDownloadFile,
}: {
t1: Freight.ClearanceT1State | null;
gatepassGranted: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
canDjAct: boolean;
canEtAct: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [closing, setClosing] = useState(false);
const uploaded = t1FilesFromWorkflow(workflowFiles);
const replaceMode = uploaded.length > 0;
@@ -680,8 +714,8 @@ function ImportT1Section({
}
const departed = Boolean(t1.trainDepartedAt);
const arrived = Boolean(t1.trainArrivedAt);
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
const canUpload =
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
return (
<Stack gap="sm">
@@ -707,7 +741,7 @@ function ImportT1Section({
<StepStatus
done
pendingLabel=""
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
doneLabel="T1 documents are final — closed by GL Ethiopia."
/>
) : !t1.wagonAllocated ? (
<StepStatus
@@ -715,6 +749,12 @@ function ImportT1Section({
pendingLabel="Waiting for operations to allocate wagons."
doneLabel=""
/>
) : !gatepassGranted ? (
<StepStatus
done={false}
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
doneLabel=""
/>
) : departed ? (
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
The train has departed T1 documents are locked and can no longer be changed.
@@ -725,6 +765,8 @@ function ImportT1Section({
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
) : uploaded.length > 0 && !canUpload ? (
<StepStatus done pendingLabel="" doneLabel="T1 documents uploaded." />
) : null}
{canUpload ? (
@@ -771,40 +813,103 @@ function ImportT1Section({
</Button>
</>
) : null}
</Stack>
);
}
{canEtAct && !t1.closed ? (
arrived ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
disabled={uploaded.length === 0}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
) : departed ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Train en route T1 can be closed once it arrives in Ethiopia.
</Alert>
) : null
) : null}
/**
* GL Ethiopia closes (accepts) the T1 set with one click once the train has
* arrived. Separate step from the GL Djibouti upload.
*/
function ImportT1CloseStep({
t1,
t1Uploaded,
canEtAct,
onChanged,
}: {
t1: Freight.ClearanceT1State | null;
t1Uploaded: boolean;
canEtAct: boolean;
onChanged?: () => void;
}) {
const [closing, setClosing] = useState(false);
if (!t1) {
return (
<StepStatus
done={false}
pendingLabel="Available once the shipment booking is created."
doneLabel=""
/>
);
}
if (t1.closed) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`T1 accepted and closed by GL Ethiopia${
t1.closedAt ? ` · ${new Date(t1.closedAt).toLocaleString()}` : ""
}`}
/>
);
}
if (!t1Uploaded) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
);
}
if (!t1.trainArrivedAt) {
return (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
{t1.trainDepartedAt
? "Train en route — T1 can be closed once it arrives in Ethiopia."
: "T1 can be closed once the train arrives in Ethiopia."}
</Alert>
);
}
if (!canEtAct) {
return (
<StepStatus
done={false}
pendingLabel="Train arrived — waiting for GL Ethiopia to close the T1."
doneLabel=""
/>
);
}
return (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
);
}