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 { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { splitMilestones } from '../contracts/clearance-milestone.catalog';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types'; import { ContractDocPhase } from '@edr/types';
@@ -1206,7 +1207,14 @@ export class BookingTransitionService {
*/ */
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> { async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); 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( throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', '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); const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) { if (outputCode) {
const setting = const setting =
@@ -1271,6 +1286,59 @@ export class BookingTransitionService {
return fresh; 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 * Customer proceeds to operation once clearance is ready. They pick the
* schedule day (the train departure day) for the shipment; the request then * 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 { clearance, customerDocs, canUpload, pending, adHoc, status } = flow;
const docNoun = bookingDocNoun(booking); const docNoun = bookingDocNoun(booking);
const reviewOpen = clearance.documentsOpen ?? canUpload; const reviewOpen = clearance.documentsOpen ?? canUpload;
// A phased (agent-cleared) booking advances through the clearance wizard // An agent-cleared booking normally advances through the clearance wizard,
// approving the last document moves it on by itself, and the server refuses // but finalizing is still allowed once every document is approved: the
// the plain finalize. Only a non-phased booking finalizes from here. // 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 phased = Boolean(clearance.phase);
const canFinalize = const canFinalize = status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
!phased && status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const finalized = [ const finalized = [
"CLEARANCE_READY", "CLEARANCE_READY",
"OPERATION_REQUEST_PENDING", "OPERATION_REQUEST_PENDING",
@@ -449,16 +449,22 @@ export function ForwarderDocumentReview({
Submit documents Submit documents
</Button> </Button>
) : null} ) : null}
{!finalized && !phased ? ( {!finalized ? (
<Button <Button
color="edr-green" color="edr-green"
radius="md" radius="md"
variant={phased ? "light" : "filled"}
leftSection={<ShieldCheck size={16} />} leftSection={<ShieldCheck size={16} />}
onClick={() => finalize.mutate()} onClick={() => finalize.mutate()}
loading={finalize.isPending} loading={finalize.isPending}
disabled={!canFinalize} 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> </Button>
) : null} ) : null}
</Group> </Group>