|
|
|
|
@@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
|
|
|
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
|
|
|
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
|
|
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
|
|
|
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
|
|
|
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
|
|
|
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
|
|
|
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
|
|
|
|
@@ -72,6 +73,8 @@ export class ContractBookingService {
|
|
|
|
|
private readonly dataSource: DataSource,
|
|
|
|
|
@Inject(forwardRef(() => TrainSchedulingService))
|
|
|
|
|
private readonly trainSchedulingService: TrainSchedulingService,
|
|
|
|
|
@Inject(forwardRef(() => BookingTransitionService))
|
|
|
|
|
private readonly bookingTransitionService: BookingTransitionService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
async createUnderContract(
|
|
|
|
|
@@ -331,6 +334,227 @@ export class ContractBookingService {
|
|
|
|
|
return { booking: result ?? booking, warnings };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initiate a BARE booking instance under a GENERAL non-customs contract
|
|
|
|
|
* (Path A per-booking self-clearance). One click, zero input: no schedule
|
|
|
|
|
* date, no cargo, no window check, no pricing. The instance starts in the
|
|
|
|
|
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
|
|
|
|
|
* Operations reviews and finalizes, and only then does the customer complete
|
|
|
|
|
* the booking (cargo + binding day + window check) via
|
|
|
|
|
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
|
|
|
|
|
*/
|
|
|
|
|
async initiateUnderContract(
|
|
|
|
|
contractId: string,
|
|
|
|
|
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
|
|
|
|
|
user?: { id?: string } | null,
|
|
|
|
|
actorPermissions?: unknown,
|
|
|
|
|
): Promise<CreateBookingUnderContractResult> {
|
|
|
|
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
|
|
|
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
|
|
|
|
|
|
|
|
|
const generalSelfClear =
|
|
|
|
|
contract.contractKind === 'GENERAL' &&
|
|
|
|
|
!contract.customsClearingEnabled &&
|
|
|
|
|
contract.tradeDirection !== 'DOMESTIC';
|
|
|
|
|
if (!generalSelfClear) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'Initiate booking applies only to general import/export contracts without customs clearing.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contract.status === 'CONTRACT_CLOSED') {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'This contract is completed — the full contracted quantity has been booked.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isGlActor =
|
|
|
|
|
actorPermissions != null &&
|
|
|
|
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
|
|
|
|
const createdByRole = await this.assertGate(contract, isGlActor);
|
|
|
|
|
|
|
|
|
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
|
|
|
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
|
|
|
|
|
|
|
|
|
// Bare instance: no cargo, no date, no price. Draws no contract capacity
|
|
|
|
|
// until the customer completes it after clearance.
|
|
|
|
|
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,
|
|
|
|
|
createdByUserId: user?.id ?? 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),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
|
|
|
|
return { booking: result ?? booking, warnings: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Complete a bare initiated booking after Operations finalized its per-booking
|
|
|
|
|
* clearance (CLEARANCE_READY) or 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.
|
|
|
|
|
*/
|
|
|
|
|
async completeUnderContract(
|
|
|
|
|
contractId: string,
|
|
|
|
|
bookingId: string,
|
|
|
|
|
dto: CreateBookingUnderContractDto,
|
|
|
|
|
): Promise<CreateBookingUnderContractResult> {
|
|
|
|
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
|
|
|
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
|
|
|
|
|
|
|
|
|
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
|
|
|
|
if (!booking || booking.contractId !== contract.id) {
|
|
|
|
|
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
|
|
|
|
}
|
|
|
|
|
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'Clearance must be finalized before the booking can be completed.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!dto.scheduledDate) {
|
|
|
|
|
throw new BadRequestException('A binding shipment day is required');
|
|
|
|
|
}
|
|
|
|
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
|
|
|
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const freightType = contract.freightType;
|
|
|
|
|
const hasCargo =
|
|
|
|
|
(booking.bookingContainers?.length ?? 0) > 0 ||
|
|
|
|
|
Number(booking.cargoTotalWeightVgm) > 0;
|
|
|
|
|
const warnings: string[] = [];
|
|
|
|
|
|
|
|
|
|
// First completion persists cargo and draws contract capacity; a resubmit
|
|
|
|
|
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
|
|
|
|
// the shipment day.
|
|
|
|
|
if (!hasCargo) {
|
|
|
|
|
await this.assertWithinQuantityCap(contract, dto);
|
|
|
|
|
if (freightType === 'CONTAINER') {
|
|
|
|
|
await this.assertWithinMaxCapacity(contract, dto);
|
|
|
|
|
await this.assert20ftPairableAtCreate(dto);
|
|
|
|
|
await this.persistContainers(booking.id, contract, dto);
|
|
|
|
|
}
|
|
|
|
|
await this.bookingsRepository.update(booking.id, {
|
|
|
|
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
|
|
|
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
|
|
|
|
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
|
|
|
|
|
} as never);
|
|
|
|
|
|
|
|
|
|
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
|
|
|
|
if (loaded) {
|
|
|
|
|
if (freightType === 'CONTAINER') {
|
|
|
|
|
await this.applyWeightResults(loaded);
|
|
|
|
|
}
|
|
|
|
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
|
|
|
|
// A zero price means no contract rate matches — roll the cargo back so
|
|
|
|
|
// the instance stays CLEARANCE_READY and can be completed again once
|
|
|
|
|
// the contract rates are fixed (the clearance work is not lost).
|
|
|
|
|
if (!(computed.totalAmount > 0)) {
|
|
|
|
|
await this.bookingsRepository.deleteContainers(booking.id);
|
|
|
|
|
await this.bookingsRepository.update(booking.id, {
|
|
|
|
|
cargoTotalWeightVgm: 0,
|
|
|
|
|
} as never);
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'Booking price came out as 0 — no contract rate matches this ' +
|
|
|
|
|
'route/cargo. Set the contract rate and try again.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await this.bookingsRepository.update(booking.id, {
|
|
|
|
|
totalAmount: computed.totalAmount,
|
|
|
|
|
priorityScore: computed.priorityScore,
|
|
|
|
|
pricingBreakdown: {
|
|
|
|
|
lineItems: computed.lineItems,
|
|
|
|
|
totalAmount: computed.totalAmount,
|
|
|
|
|
currency: computed.currency,
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
},
|
|
|
|
|
} as never);
|
|
|
|
|
await this.bookingPricingService.createPricingSnapshots(
|
|
|
|
|
booking.id,
|
|
|
|
|
computed.usedRates,
|
|
|
|
|
computed.appliedModifiers,
|
|
|
|
|
);
|
|
|
|
|
warnings.push(...computed.warnings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wagon consolidation gate — a partial-wagon 20ft set parks for a partner
|
|
|
|
|
// exactly like a drawdown created with cargo does. The shipment day is
|
|
|
|
|
// stored first so the pairing event can resume straight into the
|
|
|
|
|
// operations queue.
|
|
|
|
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
|
|
|
|
if (
|
|
|
|
|
withContainers &&
|
|
|
|
|
freightType === 'CONTAINER' &&
|
|
|
|
|
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
|
|
|
|
) {
|
|
|
|
|
await this.bookingsRepository.update(booking.id, {
|
|
|
|
|
scheduledDate: new Date(dto.scheduledDate),
|
|
|
|
|
} as never);
|
|
|
|
|
const parked = await this.consolidateDrawdown(
|
|
|
|
|
withContainers,
|
|
|
|
|
'OPERATION_REQUEST_PENDING',
|
|
|
|
|
);
|
|
|
|
|
warnings.push(parked.message);
|
|
|
|
|
if (!parked.paired) {
|
|
|
|
|
await this.maybeCompleteContract(contract);
|
|
|
|
|
const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
|
|
|
|
return { booking: pendingResult ?? booking, warnings };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Invoice the now-priced booking (idempotent, non-blocking).
|
|
|
|
|
await this.finalizeContractBooking(booking.id, contract, false);
|
|
|
|
|
await this.maybeCompleteContract(contract);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
|
|
|
|
|
// and the staff notification — the exact machine a one-time booking uses.
|
|
|
|
|
const completed = await this.bookingTransitionService.requestOperation(
|
|
|
|
|
booking.id,
|
|
|
|
|
dto.scheduledDate,
|
|
|
|
|
);
|
|
|
|
|
return { booking: completed, warnings };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
|
|
|
|
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
|
|
|
|
|