mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
enhance train scheduling and booking management
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -15,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 { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
@@ -61,6 +63,7 @@ export class ContractBookingService {
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
@@ -232,6 +235,105 @@ export class ContractBookingService {
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
|
||||
// before it can ship. Direct bookings do this at submit; drawdowns have no
|
||||
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
|
||||
// for a partner the booking is NOT invoiced or scheduled: those steps run
|
||||
// later in finalizeContractBooking, triggered by the pairing event. When it
|
||||
// pairs (or needs no consolidation) we finalize inline.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
const intendedStatus = generalCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
) {
|
||||
const parked = await this.consolidateDrawdown(
|
||||
withContainers,
|
||||
intendedStatus,
|
||||
);
|
||||
warnings.push(parked.message);
|
||||
if (!parked.paired) {
|
||||
// Waiting for a partner — stop here. The booking sits in
|
||||
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
|
||||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
return { booking: pendingResult ?? booking, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
await this.finalizeContractBooking(
|
||||
booking.id,
|
||||
contract,
|
||||
generalCustoms,
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, 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.
|
||||
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
|
||||
* emits booking.consolidation.paired, which finalizes any deferred contract
|
||||
* booking. Returns whether a partner was found plus a customer-facing message.
|
||||
*/
|
||||
private async consolidateDrawdown(
|
||||
booking: Booking,
|
||||
resumeStatus: string,
|
||||
): Promise<{ paired: boolean; message: string }> {
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
if (!slots.length) {
|
||||
return { paired: false, message: '' };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
return {
|
||||
paired: true,
|
||||
message: this.consolidationService.describePaired(
|
||||
partner.reference,
|
||||
slots,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
|
||||
return {
|
||||
paired: false,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a contract booking once it is cleared to proceed (needed no
|
||||
* consolidation, or has just paired): seed clearance milestones / link the
|
||||
* contract cycle, then generate the invoice. Idempotent — safe to call again
|
||||
* for a booking that pairs after having waited. Skips a booking that is still
|
||||
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
|
||||
*/
|
||||
private async finalizeContractBooking(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
generalCustoms: boolean,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||||
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
@@ -239,10 +341,10 @@ export class ContractBookingService {
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
await this.contractsRepository.linkBooking(cycle.id, bookingId);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
@@ -252,24 +354,22 @@ export class ContractBookingService {
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.ensureInvoiceForBooking(booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
@@ -277,8 +377,40 @@ export class ContractBookingService {
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
/**
|
||||
* A parked drawdown just paired — finalize whichever partner is a contract
|
||||
* booking that was waiting (invoice + milestones deferred at creation). The
|
||||
* pairing already resumed the booking's status from consolidationResumeStatus;
|
||||
* this runs the create-time tail that was skipped. Non-contract partners have
|
||||
* their own finalize path (staff accept) and are ignored here.
|
||||
*/
|
||||
@OnEvent('booking.consolidation.paired')
|
||||
async onConsolidationPaired(payload: {
|
||||
bookingIds: string[];
|
||||
}): Promise<void> {
|
||||
for (const id of payload.bookingIds ?? []) {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
|
||||
continue;
|
||||
}
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!contract) continue;
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
||||
(err) =>
|
||||
this.logger.error(
|
||||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user