mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
Implement clearance-first booking flow and completion process for customs contracts
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user