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')