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 * T1 transit-document lifecycle state for an import shipment booking. The
* allocation opens the upload window; train departure locks it; train arrival * gate pass (secured on the train schedule after wagon allocation) opens the
* lets GL Ethiopia close (accept) the T1 set. * upload window; train departure locks it; train arrival lets GL Ethiopia
* close (accept) the T1 set.
*/ */
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> { async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
const train = await this.trainState(bookingId); 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. * Replaces the previous batch; locked once the train departs or T1 is closed.
*/ */
async uploadT1Documents( async uploadT1Documents(
@@ -287,6 +289,12 @@ export class GlOperationsService {
'Wagons must be allocated before T1 transport documents can be uploaded.', '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) { if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); 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. * Close (accept) the T1/transport document set.
* Import: GL Ethiopia closes once the train has arrived (T1 files required). * 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( async closeT1(
bookingId: string, bookingId: string,
@@ -337,10 +346,9 @@ export class GlOperationsService {
'The transport document must be uploaded before T1 can be closed.', 'The transport document must be uploaded before T1 can be closed.',
); );
} }
const gatepass = await this.gatepassForBooking(bookingId); if (!state.trainArrivedAt) {
if (!gatepass.granted) {
throw new BadRequestException( 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. // 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: * Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ) * → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm. * → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
*/ */
export function computeExportActiveStep( export function computeExportActiveStep(
clearance: ClearanceViewLike, clearance: ClearanceViewLike,
@@ -77,8 +78,8 @@ export function computeExportActiveStep(
} }
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6; if (!clearance.train?.arrivedAt) return 6;
if (!clearance.gatepassGranted) return 7; if (!clearance.t1Closed) return 7;
if (!clearance.t1Closed) return 8; if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9; if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10; return 10;
} }
@@ -395,17 +396,9 @@ export function ExportClearanceStepper({
</Stack> </Stack>
</Stepper.Step> </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 <Stepper.Step
label="Accept T1" 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} />} icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
> >
<AcceptT1Step <AcceptT1Step
@@ -420,6 +413,14 @@ export function ExportClearanceStepper({
/> />
</Stepper.Step> </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 <Stepper.Step
label="Final invoice & payment" label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays" description="GL Djibouti invoices after offload; customer pays"
@@ -554,6 +555,7 @@ function AcceptT1Step({
}) { }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const files = exportTransitFilesFromWorkflow(workflowFiles); const files = exportTransitFilesFromWorkflow(workflowFiles);
const arrived = Boolean(clearance.train?.arrivedAt);
return ( return (
<Stack gap="sm"> <Stack gap="sm">
@@ -582,9 +584,9 @@ function AcceptT1Step({
pendingLabel={ pendingLabel={
!transportIssued !transportIssued
? "Waiting for the transport document." ? "Waiting for the transport document."
: clearance.gatepassGranted : arrived
? "Gate pass granted — GL Djibouti accepts (closes) the T1." ? "Train arrived — GL Djibouti accepts (closes) the T1."
: "Available after the gate pass is granted." : "Available once the train arrives at Djibouti."
} }
doneLabel="" doneLabel=""
/> />
@@ -592,7 +594,7 @@ function AcceptT1Step({
<Button <Button
color="edr-green" color="edr-green"
loading={loading} loading={loading}
disabled={!transportIssued || !clearance.gatepassGranted} disabled={!transportIssued || !arrived}
leftSection={<PackageCheck size={16} />} leftSection={<PackageCheck size={16} />}
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);

View File

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