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

@@ -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,
);