This commit is contained in:
marshal
2026-09-08 22:05:09 +00:00
parent 788ce6e254
commit 54f76d4779
2 changed files with 82 additions and 8 deletions

View File

@@ -44,6 +44,7 @@ import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { splitMilestones } from '../contracts/clearance-milestone.catalog';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
@@ -1206,7 +1207,14 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedCustoms(booking)) {
// A GL-cleared customs booking runs the phased ET/DJ workflow end to end,
// and finalize would jump the desks' own steps — it stays refused there.
// An agent-cleared booking is different: the clearing agent does its
// paperwork with customs off the platform, so Operations may close the
// clearance once every customer document is approved. The steps that were
// never taken are SKIPPED below rather than left pending, or the timeline
// would stall behind milestones nobody is going to complete.
if (this.isPhasedCustoms(booking) && !booking.clearedByAgent) {
throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
);
@@ -1220,6 +1228,13 @@ export class BookingTransitionService {
);
}
// Agent-cleared: close out the pre-booking steps the agent did not take,
// then hand the booking to the customer to complete. Everything below this
// point is the non-phased path and does not apply.
if (booking.clearedByAgent) {
return this.finalizeAgentClearedBooking(booking, userId);
}
const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) {
const setting =
@@ -1271,6 +1286,59 @@ export class BookingTransitionService {
return fresh;
}
/**
* Operations closes the clearance on a booking the customer handed to a
* registered clearing agent.
*
* The agent files with customs off the platform, so the steps it never
* recorded here (declaration, transit permit, pre-clearance handoff,
* Delivery Order / Release Order) are marked SKIPPED — a PENDING milestone
* would stall the phase derivation and leave the wizard mid-flow forever.
* Post-booking milestones are untouched: payment, wagons and the rail leg
* still happen, and the customer completes the booking from here.
*/
private async finalizeAgentClearedBooking(
booking: Booking,
userId?: string,
): Promise<Booking> {
const bookingId = booking.id;
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
const milestones =
await this.workflowService.listMilestonesForBooking(bookingId);
// Only the pre-booking half: anything from the boundary onward belongs to
// the shipment itself, not to clearance.
const { preBooking } = splitMilestones(tradeDirection);
const preBookingCodes = new Set(preBooking.map((d) => d.code));
const pending = milestones.filter(
(m) => preBookingCodes.has(m.milestoneCode) && m.status === 'PENDING',
);
if (pending.length > 0) {
await this.workflowService.skipMilestonesForBooking(
bookingId,
pending.map((m) => m.milestoneCode),
);
}
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? new Date(),
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'CLEARANCE_FINALIZED',
label:
pending.length > 0
? `Finalized clearance — ${pending.length} clearing-agent step(s) closed as not applicable`
: 'Finalized document approval — clearance ready',
actorId: userId ?? null,
metadata: { skipped: pending.map((m) => m.milestoneCode) },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
}
/**
* Customer proceeds to operation once clearance is ready. They pick the
* schedule day (the train departure day) for the shipment; the request then

View File

@@ -131,12 +131,12 @@ export function ForwarderDocumentReview({
const { clearance, customerDocs, canUpload, pending, adHoc, status } = flow;
const docNoun = bookingDocNoun(booking);
const reviewOpen = clearance.documentsOpen ?? canUpload;
// A phased (agent-cleared) booking advances through the clearance wizard
// approving the last document moves it on by itself, and the server refuses
// the plain finalize. Only a non-phased booking finalizes from here.
// An agent-cleared booking normally advances through the clearance wizard,
// but finalizing is still allowed once every document is approved: the
// remaining steps are closed as not applicable, for an agent that files
// with customs off the platform. The wizard stays the expected path.
const phased = Boolean(clearance.phase);
const canFinalize =
!phased && status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const canFinalize = status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const finalized = [
"CLEARANCE_READY",
"OPERATION_REQUEST_PENDING",
@@ -449,16 +449,22 @@ export function ForwarderDocumentReview({
Submit documents
</Button>
) : null}
{!finalized && !phased ? (
{!finalized ? (
<Button
color="edr-green"
radius="md"
variant={phased ? "light" : "filled"}
leftSection={<ShieldCheck size={16} />}
onClick={() => finalize.mutate()}
loading={finalize.isPending}
disabled={!canFinalize}
title={
phased
? "Closes the clearance now and marks the remaining steps as not applicable"
: undefined
}
>
Finalize clearance
{phased ? "Finalize without remaining steps" : "Finalize clearance"}
</Button>
) : null}
</Group>