Implement clearance-first booking flow and completion process for customs contracts

This commit is contained in:
Marshal
2026-07-10 18:56:39 +00:00
parent b50075dc83
commit 9ed473c309
18 changed files with 369 additions and 39 deletions

View File

@@ -988,6 +988,15 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.
if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
throw new BadRequestException(
"This booking must be completed (cargo and shipment day) before requesting operation.",
);
}
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");

View File

@@ -7,6 +7,7 @@ function mockQueryBuilder() {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
@@ -15,6 +16,8 @@ function mockQueryBuilder() {
take: jest.fn().mockReturnThis(),
getMany: jest.fn(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
getCount: jest.fn().mockResolvedValue(0),
getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }),
};
return qb;
}

View File

@@ -113,6 +113,17 @@ export class BookingRequestService {
},
};
// Clearance-first flow: the request immediately initiates a BARE booking
// instance (no cargo, no date, no price) that enters per-booking phased
// customs clearance. GL no longer screens the request up front — it
// reviews the documents in the clearance queue and completes the booking
// (container numbers, VGM, shipment day) once clearance is ready. The
// instance is created first so a failure leaves no half-linked request.
const booking = await this.contractBookingService.initiateForShipmentRequest(
contract,
{ contractRouteId: dto.contractRouteId, userId },
);
const reference = await this.generateReference();
const request = await this.repo.create({
reference,
@@ -120,7 +131,8 @@ export class BookingRequestService {
requestedByUserId: userId ?? null,
contractRouteId: dto.contractRouteId ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
status: 'PENDING',
status: 'ACCEPTED',
createdBookingId: booking.id,
requestedLines,
notes: dto.notes ?? null,
} as never);

View File

@@ -58,6 +58,31 @@ export class ClearanceMilestoneService {
await this.seed(postBooking, { bookingId });
}
/**
* Seed whichever pre/post-booking milestones the booking is still missing,
* keyed by milestoneCode. Plain seeding is a blind insert, so paths that can
* run more than once (completing an initiated instance whose pre-booking
* milestones were seeded at initiation, or a consolidation pairing replay)
* must go through this instead — a duplicate timeline breaks the phase
* derivation.
*/
async ensureBookingMilestones(
bookingId: string,
tradeDirection: string,
): Promise<void> {
const existing = await this.repo.find({ where: { bookingId } });
const have = new Set(existing.map((m) => m.milestoneCode));
const { preBooking, postBooking } = splitMilestones(tradeDirection);
await this.seed(
preBooking.filter((d) => !have.has(d.code)),
{ bookingId },
);
await this.seed(
postBooking.filter((d) => !have.has(d.code)),
{ bookingId },
);
}
private async seed(
defs: MilestoneDef[],
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },

View File

@@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
const milestoneService = {
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
ensureBookingMilestones: jest.fn().mockResolvedValue(undefined),
...overrides.milestoneService,
};
const contractsRepository = {
@@ -144,9 +145,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// GENERAL customs → per-booking pre + post milestones.
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
// GENERAL customs → per-booking milestones, via the idempotent ensure so a
// pairing replay (or an initiated instance's pre-seeded timeline) never
// duplicates rows.
expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith(
'b-1',
'EXPORT',
);
});
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {

View File

@@ -426,17 +426,100 @@ export class ContractBookingService {
}
/**
* Complete a bare initiated booking after Operations finalized its per-booking
* clearance (CLEARANCE_READY) or returned it for changes
* Initiate a BARE booking instance for a GENERAL + customs shipment request
* (Path B, clearance-first). Called by BookingRequestService.submit AFTER it
* validated the contract (general customs, active, capacity) — the request
* itself carries the quantities; the instance carries none. Pre-booking
* customs milestones are seeded immediately so the instance enters the same
* phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking.
* GL completes the booking (cargo + day) via {@link completeUnderContract}
* once the clearance reaches CLEARANCE_READY.
*/
async initiateForShipmentRequest(
contract: Contract,
opts: { contractRouteId?: string; userId?: string | null },
): Promise<Booking> {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
if (!generalCustoms) {
throw new BadRequestException(
'Shipment-request initiation applies only to general customs contracts.',
);
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const route = await this.resolveRoute(contract, opts.contractRouteId);
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole: 'CUSTOMER',
createdByUserId: opts.userId ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: this.resolveCargoTypeId(contract, {}),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: 0,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
// Pre-booking phase only — the post-booking milestones (loading, transit)
// are seeded when GL completes the booking, mirroring the ONE_TIME flow
// where GL's booking creation seeds them.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
}
/**
* Complete a bare initiated booking after its per-booking clearance is
* finalized (CLEARANCE_READY) or operations returned it for changes
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
* window + open-departure checks, pricing, consolidation and invoicing all run
* here — the same gates a one-time shipment passes at creation.
*
* Actor rules mirror {@link assertGate}: a customs (Path B) instance is
* completed by GL Ethiopia only; a non-customs (Path A) instance by the
* customer (or staff).
*/
async completeUnderContract(
contractId: string,
bookingId: string,
dto: CreateBookingUnderContractDto,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
@@ -450,6 +533,18 @@ export class ContractBookingService {
'Clearance must be finalized before the booking can be completed.',
);
}
// Path B: only GL Ethiopia completes a customs instance — the customer
// never enters shipment data on a customs contract.
if (contract.customsClearingEnabled) {
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
if (!isGlActor) {
throw new ForbiddenException(
'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.',
);
}
}
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
@@ -457,6 +552,15 @@ export class ContractBookingService {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a direct one-time booking passes at create.
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: contract.tradeDirection ?? null,
});
const freightType = contract.freightType;
const hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
@@ -541,8 +645,13 @@ export class ContractBookingService {
}
}
// Invoice the now-priced booking (idempotent, non-blocking).
await this.finalizeContractBooking(booking.id, contract, false);
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.maybeCompleteContract(contract);
}
@@ -627,12 +736,11 @@ export class ContractBookingService {
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed full milestone timeline on the booking.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
bookingId,
contract.tradeDirection,
);
await this.milestoneService.seedPostBookingMilestones(
// Per-booking clearance: seed the full milestone timeline on the booking.
// ensure* skips codes that already exist — an initiated instance carries
// its pre-booking milestones from initiation, and a consolidation pairing
// replay must not duplicate the timeline.
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
);

View File

@@ -826,8 +826,16 @@ export class ContractsController {
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.contractBookingService.completeUnderContract(id, bookingId, dto);
// Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract(
id,
bookingId,
dto,
user,
);
}
@Post(':id/validate-shipment')

View File

@@ -865,6 +865,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Completion of an initiated (bare) instance after per-booking
clearance — same form, submits to the complete endpoint. */}
<Route
path="contracts/:id/bookings/:bookingId/complete"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<GlCreateBookingForm />
</RequirePermission>
}
/>
<Route
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}

View File

@@ -145,13 +145,37 @@ function bulkUnitOfMeasure(
}
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
// With `bookingId` the form runs in COMPLETION mode: the bare instance
// (auto-initiated by the customer's shipment request) already finished its
// per-booking customs clearance, and this form supplies the deferred cargo
// (container numbers, VGM) + binding shipment day. Same window gate, same
// validation and price confirmation — the submit completes the existing
// booking instead of creating a new one.
const { id, bookingId: completeBookingId } = useParams<{
id: string;
bookingId?: string;
}>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const requestIdParam = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
// Completion mode without an explicit ?requestId=: find the shipment request
// that initiated this instance so the quantities still prefill.
const { data: contractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", id],
queryFn: () => contractsService.listBookingRequests(id!),
enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam,
});
const requestId =
requestIdParam ??
(completeBookingId
? (contractRequests?.find(
(r) => r.createdBookingId === completeBookingId,
)?.id ?? null)
: null);
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
@@ -636,6 +660,18 @@ export default function GlCreateBookingForm() {
const payload = buildPayload();
if (!payload) return;
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
mutations.completeBooking.mutate(
{ bookingId: completeBookingId, payload },
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
@@ -685,10 +721,12 @@ export default function GlCreateBookingForm() {
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Text fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Shipment Booking
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
</Text>
<Text size="sm" c="dimmed" mt={4}>
Book a shipment on behalf of the customer for contract {contract.reference}.
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
</Text>
</Box>
<Button
@@ -1261,7 +1299,11 @@ export default function GlCreateBookingForm() {
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
if (
!mutations.createBooking.isPending &&
!mutations.completeBooking.isPending
)
setPriceOpen(false);
}}
centered
radius="lg"
@@ -1413,7 +1455,10 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
disabled={
mutations.createBooking.isPending ||
mutations.completeBooking.isPending
}
>
Reject &amp; edit
</Button>
@@ -1421,7 +1466,10 @@ export default function GlCreateBookingForm() {
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
loading={
mutations.createBooking.isPending ||
mutations.completeBooking.isPending
}
disabled={
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
@@ -1429,7 +1477,7 @@ export default function GlCreateBookingForm() {
}
onClick={handleSubmit}
>
Confirm &amp; book
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
</Button>
</Group>
</Stack>

View File

@@ -211,6 +211,8 @@ export const URL_CONSTANTS = {
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.

View File

@@ -214,6 +214,27 @@ export function useContractMutations(contractId: string) {
onError: () => toast.error("Failed to create booking"),
});
const completeBooking = useMutation({
mutationFn: ({
bookingId,
payload,
}: {
bookingId: string;
payload: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.completeBookingUnderContract(
contractId,
bookingId,
payload,
),
onSuccess: () => {
toast.success("Booking completed");
void invalidateContractDetail(qc, contractId);
},
onError: (e: Error) =>
toast.error(e.message || "Failed to complete booking"),
});
const isPending =
staffAccept.isPending ||
requestChanges.isPending ||
@@ -233,6 +254,7 @@ export function useContractMutations(contractId: string) {
generateContract,
signContract,
createBooking,
completeBooking,
isPending,
};
}

View File

@@ -1,10 +1,11 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,6 +22,7 @@ import {
CheckCircle2,
Clock,
PackageCheck,
PackagePlus,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -38,10 +40,14 @@ import { useBookingMilestones } from "@/hooks/contracts/useContracts";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const { data: booking } = useBookingDetail(id);
@@ -76,6 +82,15 @@ export default function DocumentClearanceDetailPage() {
booking?.contractKind === "GENERAL" &&
Boolean(clearance?.phase);
// Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form.
const canCompleteBooking =
booking?.status === "CLEARANCE_READY" &&
Boolean(booking?.contractId) &&
Boolean(booking?.customsClearingEnabled) &&
!(Number(booking?.totalAmount ?? 0) > 0) &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -146,6 +161,22 @@ export default function DocumentClearanceDetailPage() {
</Badge>
)
}
action={
canCompleteBooking ? (
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete`,
)
}
>
Complete booking
</Button>
) : undefined
}
/>
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />

View File

@@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() {
<Stack gap="lg">
<PageHeader
title="Shipment Requests"
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
subtitle="Customer requests to ship under general customs contracts. Each request starts its booking's clearance immediately — complete the booking from the clearance page once it is ready."
meta={
<Badge
variant="light"

View File

@@ -502,6 +502,31 @@ export const contractsService = {
};
},
/**
* Complete a bare initiated booking instance once its per-booking clearance
* is CLEARANCE_READY — same payload as create; the API persists cargo,
* prices, invoices, checks the booking window and moves the booking to the
* operations queue.
*/
completeBookingUnderContract: async (
id: string,
bookingId: string,
payload: Freight.CreateBookingUnderContractDto,
): Promise<{ id: string; reference: string; warnings?: string[] }> => {
const result = await postContract<{
booking?: { id: string; reference: string };
id?: string;
reference?: string;
warnings?: string[];
}>(C.BOOKINGS_COMPLETE(id, bookingId), payload);
const booking = result.booking ?? result;
return {
id: booking.id ?? "",
reference: booking.reference ?? "",
warnings: result.warnings,
};
},
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +

View File

@@ -106,7 +106,10 @@ function BookingActionModalBody({
Complete booking
</Button>
) : (
flow.isReady && (
// Customs bare instances await GL completion — no customer
// proceed button (the server rejects it anyway).
flow.isReady &&
!flow.awaitingGlCompletion && (
<Button
color="edr-green"
radius="md"

View File

@@ -55,6 +55,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
glDocs,
isReady,
needsCompletion,
awaitingGlCompletion,
canUpload,
isInitialUpload,
status,
@@ -81,9 +82,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{needsCompletion
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
: clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Clearance is ready. You can now proceed to operation."}
: awaitingGlCompletion
? "Customs clearance is complete. Global Logistics is completing your booking (cargo details and shipment day) — you will be notified when payment is due."
: clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Clearance is ready. You can now proceed to operation."}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
@@ -211,7 +214,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
</Alert>
)}
{isReady && !needsCompletion && (
{isReady && !needsCompletion && !awaitingGlCompletion && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day

View File

@@ -67,16 +67,20 @@ export function useClearanceFlow(booking: Freight.IBooking) {
);
const isReady = status === "CLEARANCE_READY";
// Bare initiated instance (GENERAL non-customs "Initiate booking"): created
// with no cargo and no price. Once ready it is COMPLETED on the full booking
// form (cargo + shipment day + window check), not date-only proceed.
// Bare initiated instance: created with no cargo and no price; completion
// (cargo + shipment day + window check) happens on the full booking form.
const isBareInstance =
Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0);
// Non-customs (Path A): the CUSTOMER completes the booking himself.
const needsCompletion =
isReady &&
Boolean(booking.contractId) &&
!(Number(booking.totalAmount ?? 0) > 0);
isReady && isBareInstance && !clearance?.includesCustoms;
const completeTo = needsCompletion
? `/contracts/${booking.contractId}/bookings/${booking.id}/complete`
: null;
// Customs (Path B): GL completes on the customer's behalf — the customer
// just sees that clearance is done and GL is preparing the booking.
const awaitingGlCompletion =
isReady && isBareInstance && Boolean(clearance?.includesCustoms);
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
// The very first upload (nothing in review yet). Here every required document
@@ -158,6 +162,7 @@ export function useClearanceFlow(booking: Freight.IBooking) {
isReady,
needsCompletion,
completeTo,
awaitingGlCompletion,
canUpload,
isInitialUpload,
// staged upload state

View File

@@ -44,9 +44,18 @@ export default function NewShipmentRequestPage() {
const submit = useMutation({
mutationFn: (dto: Freight.CreateBookingRequestDto) =>
contractsService.submitBookingRequest(id!, dto),
onSuccess: () => {
toast.success("Shipment request submitted");
navigate(`/contracts/${id}`);
onSuccess: (request) => {
// Clearance-first flow: the request auto-initiates a booking instance —
// send the customer straight to it to upload clearance documents.
if (request.createdBookingId) {
toast.success(
"Shipment initiated — upload your clearance documents to start the review.",
);
navigate(`/bookings/${request.createdBookingId}`);
} else {
toast.success("Shipment request submitted");
navigate(`/contracts/${id}`);
}
},
onError: (e: Error) => toast.error(e.message || "Could not submit request"),
});