mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1779 lines
68 KiB
TypeScript
1779 lines
68 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
Optional,
|
|
} from "@nestjs/common";
|
|
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
|
import { DataSource } from "typeorm";
|
|
|
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
|
|
|
import {
|
|
BookingBatchService,
|
|
type ExportTrainOption,
|
|
} from '../train-scheduling/booking-batch.service';
|
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
|
import { isRoadService } from './road.util';
|
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
|
import { FilesService } from '../files/files.service';
|
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
|
import { BookingContractService } from './booking-contract.service';
|
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
|
import { BookingPricingService } from './booking-pricing.service';
|
|
import { ContainerValidationService } from './container-validation.service';
|
|
import { BookingsRepository } from './bookings.repository';
|
|
import { assertBookingStatus } from './booking-status.util';
|
|
import {
|
|
adHocLabel,
|
|
clearanceCodesForBooking,
|
|
clearanceDocumentsOpen,
|
|
} from './clearance.util';
|
|
import {
|
|
buildClearanceDocHistory,
|
|
type ClearanceDocEvent,
|
|
} from './clearance-doc-history.util';
|
|
import { ClearanceEventService } from './clearance-event.service';
|
|
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
|
import { Booking } from './entities/booking.entity';
|
|
import { BookingsService } from './bookings.service';
|
|
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
|
import { ContractDocPhase } from '@edr/types';
|
|
|
|
import { BookingInvoiceService } from "./booking-invoice.service";
|
|
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
|
|
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
|
|
|
|
@Injectable()
|
|
export class BookingTransitionService {
|
|
private readonly logger = new Logger(BookingTransitionService.name);
|
|
constructor(
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly ruleEngineService: RuleEngineService,
|
|
private readonly pricingService: BookingPricingService,
|
|
@Inject(forwardRef(() => BookingContractService))
|
|
private readonly contractService: BookingContractService,
|
|
private readonly filesService: FilesService,
|
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
|
@Inject(forwardRef(() => BookingBatchService))
|
|
private readonly bookingBatchService: BookingBatchService,
|
|
@Inject(forwardRef(() => BookingsService))
|
|
private readonly bookingsService: BookingsService,
|
|
@Inject(forwardRef(() => BookingClearanceService))
|
|
private readonly bookingClearanceService: BookingClearanceService,
|
|
@Inject(forwardRef(() => ClearanceWorkflowService))
|
|
private readonly workflowService: ClearanceWorkflowService,
|
|
// forwardRef: booking-invoice.service now pulls in the wagon-cancellation
|
|
// service, whose cross-module imports close a require cycle through this
|
|
// file — without it the class is undefined at decorator time.
|
|
@Inject(forwardRef(() => BookingInvoiceService))
|
|
private readonly invoiceService: BookingInvoiceService,
|
|
private readonly containerValidationService: ContainerValidationService,
|
|
private readonly notifier: BookingLifecycleNotifierService,
|
|
private readonly clearanceEvents: ClearanceEventService,
|
|
private readonly events: EventEmitter2,
|
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
|
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
|
// compiling; Nest injects it normally at runtime.
|
|
@Optional() private readonly dataSource?: DataSource,
|
|
) {}
|
|
|
|
private isPhasedCustoms(booking: Booking): boolean {
|
|
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
|
|
}
|
|
|
|
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
|
private async assert20ftPairable(booking: Booking): Promise<void> {
|
|
// Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called
|
|
// right after this gate) auto-pairs the odd leftover with another
|
|
// customer's odd booking or parks the booking as PENDING_CONSOLIDATION.
|
|
// Only the weight-pairing rule hard-blocks.
|
|
const violations =
|
|
await this.containerValidationService.validate20ftPairing(booking);
|
|
if (violations.length) {
|
|
throw new BadRequestException(
|
|
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
|
|
.map((v) => v.message)
|
|
.join(' ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
|
|
|
if (Number(booking.totalAmount) <= 0) {
|
|
throw new BadRequestException(
|
|
"Generate a price before submitting (POST /bookings/:id/generate-price)",
|
|
);
|
|
}
|
|
|
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
|
this.ruleEngineService.assertNoHardBlocks({
|
|
priorityScore: computed.priorityScore,
|
|
appliedModifiers: computed.appliedModifiers,
|
|
containerWeightResults: [],
|
|
warnings: computed.warnings,
|
|
hardBlocked: computed.hardBlocked,
|
|
requiresDirectorApproval: false,
|
|
});
|
|
|
|
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
|
|
// If no balanced pairing exists the booking cannot proceed (overweight only
|
|
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
|
|
await this.assert20ftPairable(booking);
|
|
|
|
const stored = booking.pricingBreakdown as {
|
|
lineItems?: PriceLineItemDto[];
|
|
totalAmount?: number;
|
|
} | null;
|
|
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
|
const priorityScore =
|
|
await this.pricingService.computeSubmitPriorityScore(booking);
|
|
|
|
if (unchanged) {
|
|
await this.pricingService.createPricingSnapshots(
|
|
bookingId,
|
|
computed.usedRates,
|
|
computed.appliedModifiers,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "SUBMITTED",
|
|
priorityScore,
|
|
} as never);
|
|
|
|
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
|
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
|
// arrives. The returned status reflects that outcome.
|
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
|
updated!.id,
|
|
);
|
|
if (finalBooking.status === "SUBMITTED") {
|
|
this.notifier.submittedToStaff(finalBooking);
|
|
}
|
|
return {
|
|
bookingId: finalBooking.id,
|
|
status: finalBooking.status,
|
|
priceChanged: false,
|
|
totalAmount: Number(finalBooking.totalAmount),
|
|
currency: finalBooking.paymentCurrency,
|
|
lineItems: computed.lineItems,
|
|
};
|
|
}
|
|
|
|
const previousTotalAmount = Number(booking.totalAmount);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
totalAmount: computed.totalAmount,
|
|
priorityScore: computed.priorityScore,
|
|
pricingBreakdown: {
|
|
lineItems: computed.lineItems,
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
generatedAt: new Date().toISOString(),
|
|
},
|
|
status: "PRICE_CHANGED_PENDING_CONFIRM",
|
|
} as never);
|
|
|
|
const updatedBooking = await this.bookingsService.findById(bookingId);
|
|
return {
|
|
bookingId: updatedBooking.id,
|
|
status: updatedBooking.status,
|
|
priceChanged: true,
|
|
previousTotalAmount,
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
lineItems: computed.lineItems,
|
|
message:
|
|
"Price has changed since preview. Confirm to submit with the updated price.",
|
|
};
|
|
}
|
|
|
|
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
|
|
|
|
if (Number(booking.totalAmount) <= 0) {
|
|
throw new BadRequestException("No price to confirm");
|
|
}
|
|
|
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
|
this.ruleEngineService.assertNoHardBlocks({
|
|
priorityScore: computed.priorityScore,
|
|
appliedModifiers: computed.appliedModifiers,
|
|
containerWeightResults: [],
|
|
warnings: computed.warnings,
|
|
hardBlocked: computed.hardBlocked,
|
|
requiresDirectorApproval: false,
|
|
});
|
|
await this.assert20ftPairable(booking);
|
|
|
|
await this.pricingService.createPricingSnapshots(
|
|
bookingId,
|
|
computed.usedRates,
|
|
computed.appliedModifiers,
|
|
);
|
|
|
|
const priorityScore =
|
|
await this.pricingService.computeSubmitPriorityScore(booking);
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "SUBMITTED",
|
|
priorityScore,
|
|
totalAmount: computed.totalAmount,
|
|
pricingBreakdown: {
|
|
lineItems: computed.lineItems,
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
generatedAt: new Date().toISOString(),
|
|
},
|
|
} as never);
|
|
|
|
// Same consolidation treatment as the direct submit path.
|
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
|
updated!.id,
|
|
);
|
|
if (finalBooking.status === "SUBMITTED") {
|
|
this.notifier.submittedToStaff(finalBooking);
|
|
}
|
|
return {
|
|
bookingId: finalBooking.id,
|
|
status: finalBooking.status,
|
|
priceChanged: false,
|
|
totalAmount: Number(finalBooking.totalAmount),
|
|
currency: finalBooking.paymentCurrency,
|
|
lineItems: computed.lineItems,
|
|
message: "Booking submitted with confirmed price.",
|
|
};
|
|
}
|
|
|
|
async requestChanges(
|
|
bookingId: string,
|
|
note: string,
|
|
actorId: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["SUBMITTED"]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
note,
|
|
"CHANGES_REQUESTED",
|
|
actorId,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "CHANGES_REQUESTED",
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(updated!.id);
|
|
this.notifier.changesRequested(fresh, note);
|
|
return fresh;
|
|
}
|
|
|
|
async acceptIntake(
|
|
bookingId: string,
|
|
actorId: string,
|
|
validityDays: number,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
|
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
|
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
|
assertBookingStatus(booking, ["SUBMITTED"]);
|
|
|
|
// The backoffice must define how long the accepted contract stays valid.
|
|
// Without a window the contract has no end date and cannot be relied on, so
|
|
// accept is blocked until a positive number of days is supplied.
|
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
|
throw new BadRequestException(
|
|
"A contract validity (in days) is required to accept this booking.",
|
|
);
|
|
}
|
|
|
|
// Validity runs from the accept moment through accept + N days.
|
|
const validFrom = new Date();
|
|
const validUntil = new Date(validFrom);
|
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
|
|
|
// Bookings no longer run a multi-step approval chain — accepting the intake
|
|
// approves the booking outright and generates its contract. (The approval
|
|
// chain is a contract-only concern now; see contract-transition.service.)
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "APPROVED",
|
|
approvedByStaffId: actorId,
|
|
approvedByStaffAt: validFrom,
|
|
contractValidityDays: validityDays,
|
|
contractValidFrom: validFrom,
|
|
contractValidUntil: validUntil,
|
|
} as never);
|
|
|
|
// Generating the contract is best-effort: the acceptance is already
|
|
// committed, so a failure here must not roll it back. The booking stays
|
|
// APPROVED and staff can retry generation from the booking page.
|
|
try {
|
|
await this.contractService.generateContract(bookingId);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
|
|
`The booking is APPROVED — retry generation from the booking page.`,
|
|
);
|
|
}
|
|
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.accepted(fresh);
|
|
this.notifier.approved(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
async staffReject(
|
|
bookingId: string,
|
|
reason: string,
|
|
actorId: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason,
|
|
"REJECTION",
|
|
actorId,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "REJECTED",
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(updated!.id);
|
|
this.notifier.rejected(fresh, reason);
|
|
return fresh;
|
|
}
|
|
|
|
async customerSign(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "SIGNED_CUSTOMER",
|
|
customerSignedAt: new Date(),
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(updated!.id);
|
|
this.notifier.customerSignedToStaff(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
async startTransit(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["PAID"]);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "IN_TRANSIT",
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(updated!.id);
|
|
this.notifier.inTransit(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
|
|
* warehouses module delivered the goods and asks the booking to complete.
|
|
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
|
|
*/
|
|
@OnEvent('import.handover.completed')
|
|
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
|
|
try {
|
|
await this.complete(payload.bookingId);
|
|
} catch (err) {
|
|
this.logger.log(
|
|
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async complete(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "COMPLETED",
|
|
endDate: new Date(),
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(updated!.id);
|
|
this.notifier.completed(fresh);
|
|
// A ONE_TIME contract closes on its single shipment being delivered.
|
|
this.events.emit('booking.completed', { bookingId });
|
|
// Customer tracking: close out the tail milestones so a finished shipment
|
|
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
|
// implied by delivery; a storage invoice that was never raised is skipped
|
|
// (storage billing does not apply to every shipment). All doc-trigger /
|
|
// best-effort — a booking without milestone rows is untouched.
|
|
if (this.milestoneService) {
|
|
for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
|
|
try {
|
|
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
|
|
} catch {
|
|
/* tracking must never block completion */
|
|
}
|
|
}
|
|
try {
|
|
await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
|
|
} catch {
|
|
/* no such milestone row (export / non-customs) — fine */
|
|
}
|
|
}
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons
|
|
* release immediately instead of tying up the train until the pay window
|
|
* lapses. Ends CANCELLED; the freed capacity tops up from the waiting list.
|
|
*/
|
|
async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
|
|
// Consolidated pair: the shared wagon dies with this hold. An unpaid
|
|
// partner's hold is released with it (both cancel, no fee); a PAID partner
|
|
// keeps the whole wagon and this canceller owes the cancellation fee.
|
|
const partnerId = booking.consolidationPartnerId;
|
|
if (partnerId) {
|
|
const partner = await this.bookingsService.findById(partnerId);
|
|
const partnerPaid =
|
|
partner.paymentStatus === "PAID" || partner.status === "PAID";
|
|
await this.bookingsRepository.clearConsolidationPair(
|
|
booking.id,
|
|
partnerId,
|
|
);
|
|
if (partnerPaid) {
|
|
this.events.emit("booking.consolidation.partnerLapsed", {
|
|
expiredBookingId: booking.id,
|
|
});
|
|
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
|
const partnerReason = "Cancelled with its consolidation partner";
|
|
await this.bookingsRepository.createReviewNote(
|
|
partnerId,
|
|
partnerReason,
|
|
"REJECTION",
|
|
);
|
|
if (partner.status === "SELECTED_FOR_BATCH") {
|
|
await this.bookingBatchService.cancelReservation(partnerId);
|
|
} else {
|
|
await this.invoiceService.expireOpenInvoices(partnerId);
|
|
await this.bookingsRepository.update(partnerId, {
|
|
status: "CANCELLED",
|
|
} as never);
|
|
}
|
|
this.notifier.cancelled(
|
|
await this.bookingsService.findById(partnerId),
|
|
partnerReason,
|
|
);
|
|
}
|
|
}
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason ?? "Customer cancelled before payment",
|
|
"REJECTION",
|
|
);
|
|
await this.bookingBatchService.cancelReservation(bookingId);
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.cancelled(fresh, reason ?? "Cancelled before payment");
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Customer self-service cancel, allowed only before payment — no fee.
|
|
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
|
|
* take the plain cancel path (open invoices expired, nothing reserved yet).
|
|
* Anything past payment falls through to cancel()'s status assertion.
|
|
*/
|
|
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
if (booking.status === "SELECTED_FOR_BATCH") {
|
|
return this.cancelHold(bookingId, reason);
|
|
}
|
|
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
|
}
|
|
|
|
/**
|
|
* Run a staff decision across BOTH halves of a consolidated pair.
|
|
*
|
|
* Two bookings that share a wagon must move together: accepting one while the
|
|
* other stays behind would put half a wagon into the approval chain, and
|
|
* cancelling one alone would strand the other on a wagon it can no longer
|
|
* fill. All-or-nothing — if either half throws, the transaction rolls back and
|
|
* neither booking moved.
|
|
*
|
|
* Each half still runs the ordinary single-booking transition, so pricing,
|
|
* invoicing and notifications stay per booking: the customers are billed and
|
|
* notified separately, exactly as they are today.
|
|
*/
|
|
async applyPairedDecision(
|
|
bookingId: string,
|
|
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
|
|
actorId: string,
|
|
options: { reason?: string; note?: string; validityDays?: number } = {},
|
|
): Promise<{ booking: Booking; partner: Booking }> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
const partnerId = booking.consolidationPartnerId;
|
|
if (!partnerId) {
|
|
throw new BadRequestException(
|
|
"This booking has no consolidation partner — use the single-booking action.",
|
|
);
|
|
}
|
|
|
|
// cancel() carries its own pair cascade (it settles the partner too), so
|
|
// running it twice would trip on the already-cancelled partner.
|
|
if (decision === "cancel") {
|
|
const own = await this.cancel(
|
|
bookingId,
|
|
options.reason ?? "Cancelled with its consolidation partner",
|
|
);
|
|
const other = await this.bookingsService.findById(partnerId);
|
|
return { booking: own, partner: other };
|
|
}
|
|
|
|
const runOne = async (id: string): Promise<Booking> => {
|
|
switch (decision) {
|
|
case "accept":
|
|
// Same requirement as the single-booking accept: the approval chain
|
|
// needs a contract validity window.
|
|
if (!(Number(options.validityDays) > 0)) {
|
|
throw new BadRequestException(
|
|
"Contract validity (days) is required to accept.",
|
|
);
|
|
}
|
|
return this.acceptIntake(id, actorId, Number(options.validityDays));
|
|
case "operationAccept":
|
|
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
|
|
note: options.note,
|
|
});
|
|
case "requestChanges":
|
|
return this.requestChanges(id, options.note ?? "", actorId);
|
|
}
|
|
};
|
|
|
|
// Without a DataSource (unit tests hand-construct this service) fall back to
|
|
// running the two halves directly — the ordering guarantee still holds, only
|
|
// the rollback does not.
|
|
if (!this.dataSource) {
|
|
const own = await runOne(bookingId);
|
|
const other = await runOne(partnerId);
|
|
return { booking: own, partner: other };
|
|
}
|
|
|
|
return this.dataSource.transaction(async () => {
|
|
// Sequential: one connection per transaction context.
|
|
const own = await runOne(bookingId);
|
|
const other = await runOne(partnerId);
|
|
return { booking: own, partner: other };
|
|
});
|
|
}
|
|
|
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"DRAFT",
|
|
"SUBMITTED",
|
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
|
"CHANGES_REQUESTED",
|
|
"PENDING_APPROVAL",
|
|
"CONTRACT_READY",
|
|
"OPERATION_REQUEST_PENDING",
|
|
// A booking parked waiting for a consolidation partner can be walked
|
|
// away from — nothing is reserved yet.
|
|
"PENDING_CONSOLIDATION",
|
|
]);
|
|
|
|
// Consolidated pair: a shared wagon never ships half-full, so cancelling
|
|
// one half settles the other too. Neither paid → both cancel, no fee. A
|
|
// PAID partner instead keeps the whole wagon and the unpaid canceller
|
|
// owes the cancellation fee (opened by the partnerLapsed listener). A
|
|
// PAID booking itself never comes through here (status gate above) — it
|
|
// cancels via wagon cancellation, where the fee machinery lives.
|
|
const partnerId = booking.consolidationPartnerId;
|
|
if (partnerId) {
|
|
const partner = await this.bookingsService.findById(partnerId);
|
|
const partnerPaid =
|
|
partner.paymentStatus === "PAID" || partner.status === "PAID";
|
|
await this.bookingsRepository.clearConsolidationPair(
|
|
booking.id,
|
|
partnerId,
|
|
);
|
|
if (partnerPaid) {
|
|
this.events.emit("booking.consolidation.partnerLapsed", {
|
|
expiredBookingId: booking.id,
|
|
});
|
|
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
|
const partnerReason = "Cancelled with its consolidation partner";
|
|
await this.bookingsRepository.createReviewNote(
|
|
partnerId,
|
|
partnerReason,
|
|
"REJECTION",
|
|
);
|
|
await this.invoiceService.expireOpenInvoices(partnerId);
|
|
if (partner.status === "SELECTED_FOR_BATCH") {
|
|
// Reserved hold: release the wagons through the batch engine.
|
|
await this.bookingBatchService.cancelReservation(partnerId);
|
|
} else {
|
|
await this.bookingsRepository.update(partnerId, {
|
|
status: "CANCELLED",
|
|
} as never);
|
|
}
|
|
this.notifier.cancelled(
|
|
await this.bookingsService.findById(partnerId),
|
|
partnerReason,
|
|
);
|
|
}
|
|
}
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason,
|
|
"REJECTION",
|
|
);
|
|
|
|
// Stop the open-invoice leak: a cancelled booking must not leave a payable
|
|
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
|
await this.invoiceService.expireOpenInvoices(bookingId);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "CANCELLED",
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(updated!.id);
|
|
this.notifier.cancelled(fresh, reason);
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Customer rejects the priced booking at the confirm step. The booking becomes
|
|
* REJECTED (terminal) — the customer starts a new booking rather than editing
|
|
* this one. Only a not-yet-committed booking can be rejected this way.
|
|
*/
|
|
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"DRAFT",
|
|
"SUBMITTED",
|
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
|
"PENDING_CONSOLIDATION",
|
|
]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason?.trim() || "Customer rejected the price estimate.",
|
|
"REJECTION",
|
|
);
|
|
|
|
// Stop the open-invoice leak: a rejected booking must not leave a payable
|
|
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
|
await this.invoiceService.expireOpenInvoices(bookingId);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "REJECTED",
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
|
|
|
/**
|
|
* The clearance document grid for a booking: each required field from the
|
|
* resolved customer-input set (and the GL-output set for customs) with its
|
|
* uploaded file and GL review status. Drives both portals' clearance UI.
|
|
*/
|
|
async getClearanceView(bookingId: string): Promise<{
|
|
status: string;
|
|
includesCustoms: boolean;
|
|
inputCode: string | null;
|
|
outputCode: string | null;
|
|
documents: Array<{
|
|
fileKey: string;
|
|
label: string;
|
|
required: boolean;
|
|
uploadedBy: "customer" | "gl";
|
|
settingCode: string;
|
|
file: { id: string; name: string; url: string } | null;
|
|
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
|
note: string | null;
|
|
uploadedAt: string | null;
|
|
reviewedAt: string | null;
|
|
reviewedByName: string | null;
|
|
history: ClearanceDocEvent[];
|
|
}>;
|
|
allApproved: boolean;
|
|
documentsOpen: boolean;
|
|
docRequests: Array<{
|
|
id: string;
|
|
note: string;
|
|
byName: string | null;
|
|
at: string;
|
|
}>;
|
|
phase?: string | null;
|
|
milestones?: unknown[];
|
|
nextAction?: unknown;
|
|
dutyRequired?: boolean | null;
|
|
roHold?: boolean;
|
|
roHoldReason?: string | null;
|
|
vesselDepartureDate?: string | null;
|
|
operationReady?: boolean;
|
|
}> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
if (this.isPhasedCustoms(booking)) {
|
|
return this.bookingClearanceService.getClearanceView(bookingId);
|
|
}
|
|
const { inputCode, outputCode, includesCustoms } =
|
|
clearanceCodesForBooking(booking);
|
|
|
|
const files = await this.filesService.findByResource(bookingId, "bookings");
|
|
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
|
const reviews =
|
|
await this.bookingsRepository.findDocumentReviews(bookingId);
|
|
const reviewByKey = new Map(
|
|
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
|
);
|
|
const allVersions = await this.filesService.findAllVersionsByResource(
|
|
bookingId,
|
|
"bookings",
|
|
);
|
|
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
|
bookingId,
|
|
"CHANGES_REQUESTED",
|
|
);
|
|
const docRequestNotes = await this.bookingsRepository.findReviewNotes(
|
|
bookingId,
|
|
"ADDITIONAL_DOC_REQUEST",
|
|
);
|
|
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
|
...reviews.map((r) => r.reviewedByStaffId),
|
|
...queryNotes.map((n) => n.authorId),
|
|
...docRequestNotes.map((n) => n.authorId),
|
|
]);
|
|
|
|
const documents: Awaited<
|
|
ReturnType<BookingTransitionService["getClearanceView"]>
|
|
>["documents"] = [];
|
|
|
|
const pushSetting = async (
|
|
code: string | null,
|
|
uploadedBy: "customer" | "gl",
|
|
) => {
|
|
if (!code) return;
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(code);
|
|
} catch {
|
|
return; // setting not seeded — skip gracefully
|
|
}
|
|
for (const field of setting.fields ?? []) {
|
|
const file = fileByCode.get(field.fileKey) ?? null;
|
|
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
|
documents.push({
|
|
fileKey: field.fileKey,
|
|
label: field.fileLabel,
|
|
required: field.isRequired,
|
|
uploadedBy,
|
|
settingCode: code,
|
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
|
reviewStatus: review?.status ?? null,
|
|
note: review?.note ?? null,
|
|
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
|
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
|
reviewedByName: review?.reviewedByStaffId
|
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
|
: null,
|
|
history: buildClearanceDocHistory({
|
|
fileKey: field.fileKey,
|
|
allVersions,
|
|
queryNotes,
|
|
review,
|
|
names: reviewerNames,
|
|
}),
|
|
});
|
|
}
|
|
};
|
|
|
|
await pushSetting(inputCode, "customer");
|
|
await pushSetting(outputCode, "gl");
|
|
|
|
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
|
for (const f of files) {
|
|
if (!f.code?.startsWith("custom_")) continue;
|
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
|
documents.push({
|
|
fileKey: f.code,
|
|
// What the customer called it, falling back to the filename for rows
|
|
// uploaded before the name was carried through.
|
|
label: f.title || adHocLabel(f.code) || f.name,
|
|
required: false,
|
|
uploadedBy: "customer",
|
|
settingCode: "custom",
|
|
file: { id: f.id, name: f.name, url: f.url },
|
|
reviewStatus: review?.status ?? null,
|
|
note: review?.note ?? null,
|
|
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
|
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
|
reviewedByName: review?.reviewedByStaffId
|
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
|
: null,
|
|
history: buildClearanceDocHistory({
|
|
fileKey: f.code,
|
|
allVersions,
|
|
queryNotes,
|
|
review,
|
|
names: reviewerNames,
|
|
}),
|
|
});
|
|
}
|
|
|
|
const allApproved = await this.isClearanceFullyApproved(booking);
|
|
|
|
return {
|
|
status: booking.status,
|
|
includesCustoms,
|
|
inputCode,
|
|
outputCode,
|
|
documents,
|
|
allApproved,
|
|
documentsOpen: clearanceDocumentsOpen(booking),
|
|
docRequests: docRequestNotes.map((n) => ({
|
|
id: n.id,
|
|
note: n.note,
|
|
byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null,
|
|
at: n.createdAt.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* GL asks the customer for additional clearance document(s). Stored as a
|
|
* review-note thread shown on both the GL clearance page and the customer's
|
|
* portal; the customer answers with an ad-hoc upload. Allowed for as long as
|
|
* documents are open (until the shipment is paid).
|
|
*/
|
|
async requestAdditionalDocuments(
|
|
bookingId: string,
|
|
note: string,
|
|
staffId: string,
|
|
): Promise<void> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
if (!clearanceDocumentsOpen(booking)) {
|
|
throw new ConflictException(
|
|
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
|
);
|
|
}
|
|
if (!note?.trim()) {
|
|
throw new BadRequestException("Describe the document(s) you need.");
|
|
}
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
note.trim(),
|
|
"ADDITIONAL_DOC_REQUEST",
|
|
staffId,
|
|
);
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: "ADDITIONAL_DOCS_REQUESTED",
|
|
label: "Requested additional document(s) from the customer",
|
|
actorId: staffId,
|
|
metadata: { note: note.trim() },
|
|
});
|
|
this.notifier.additionalDocsRequested(booking, note.trim());
|
|
}
|
|
|
|
/**
|
|
* True when every REQUIRED field of the booking's customer-input clearance set
|
|
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
|
*/
|
|
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
|
const { inputCode } = clearanceCodesForBooking(booking);
|
|
if (!inputCode) return true; // no gate applies (e.g. domestic)
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
|
if (required.length === 0) return true;
|
|
const reviews = await this.bookingsRepository.findDocumentReviews(
|
|
booking.id,
|
|
);
|
|
return required.every((field) =>
|
|
reviews.some(
|
|
(r) =>
|
|
r.settingCode === inputCode &&
|
|
r.fileKey === field.fileKey &&
|
|
r.status === "APPROVED",
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Customer uploads clearance documents. Each multipart file's fieldname is the
|
|
* field's fileKey (or custom_<n> for ad-hoc). Saves FileRecords, refreshes the
|
|
* per-document review rows to PENDING, and moves the booking into review.
|
|
*/
|
|
async submitClearanceDocuments(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
// Documents stay open until the shipment is paid — a customs shipment keeps
|
|
// collecting paperwork (amended invoices, port documents) well past
|
|
// clearance finalization. See {@link clearanceDocumentsOpen}.
|
|
if (!clearanceDocumentsOpen(booking)) {
|
|
throw new ConflictException(
|
|
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
|
);
|
|
}
|
|
const { inputCode } = clearanceCodesForBooking(booking);
|
|
if (!inputCode) {
|
|
throw new BadRequestException(
|
|
"This booking has no document-clearance step",
|
|
);
|
|
}
|
|
if (files.length === 0) {
|
|
throw new BadRequestException("No documents uploaded");
|
|
}
|
|
|
|
// First submission (nothing in review yet): every required input field must
|
|
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
|
// is only fixing queried/pending docs, so the already-uploaded required docs
|
|
// stay in place and we don't re-gate on the full required set.
|
|
if (booking.status === "AWAITING_DOCUMENTS") {
|
|
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
|
}
|
|
|
|
for (const file of files) {
|
|
const record = await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: "bookings",
|
|
code: file.fieldname,
|
|
file,
|
|
// Ad-hoc uploads carry the name the customer typed (fieldname
|
|
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
|
|
// of a raw filename like "scan_003.pdf".
|
|
title: adHocLabel(file.fieldname),
|
|
});
|
|
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
|
const settingCode = file.fieldname.startsWith("custom_")
|
|
? "custom"
|
|
: inputCode;
|
|
await this.bookingsRepository.upsertDocumentReviewPending({
|
|
bookingId,
|
|
settingCode,
|
|
fileKey: file.fieldname,
|
|
fileRecordId: record.id,
|
|
});
|
|
}
|
|
|
|
// Only the pre-finalization submission drives the booking into review.
|
|
// A later addition (an amended invoice while the shipment is already
|
|
// scheduled) must never rewind the status or reopen the phased workflow —
|
|
// it lands as a new PENDING document for GL to approve where it stands.
|
|
const inDocumentPhase =
|
|
booking.status === "AWAITING_DOCUMENTS" ||
|
|
booking.status === "DOCUMENTS_UNDER_REVIEW";
|
|
|
|
if (inDocumentPhase) {
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "DOCUMENTS_UNDER_REVIEW",
|
|
} as never);
|
|
|
|
if (this.isPhasedCustoms(booking)) {
|
|
await this.workflowService.onCustomerDocsUploadedForBooking(
|
|
bookingId,
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
);
|
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
|
} as never);
|
|
}
|
|
}
|
|
|
|
const fileKeys = files.map((f) => f.fieldname);
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'DOCS_SUBMITTED',
|
|
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
|
|
actorType: 'CUSTOMER',
|
|
actorId: userId ?? null,
|
|
metadata: { fileKeys },
|
|
});
|
|
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Guard for the first clearance submission: every required field of the
|
|
* booking's customer-input set must be covered, either by a file already on
|
|
* the booking or by one in this upload batch. Keeps the customer from starting
|
|
* review with required documents missing.
|
|
*/
|
|
private async assertRequiredInputsPresent(
|
|
bookingId: string,
|
|
inputCode: string,
|
|
files: Express.Multer.File[],
|
|
): Promise<void> {
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
|
} catch {
|
|
return; // setting not seeded — nothing to enforce
|
|
}
|
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
|
if (required.length === 0) return;
|
|
|
|
const existing = await this.filesService.findByResource(
|
|
bookingId,
|
|
"bookings",
|
|
);
|
|
const presentKeys = new Set<string>([
|
|
...existing.map((f) => f.code),
|
|
...files.map((f) => f.fieldname),
|
|
]);
|
|
|
|
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
|
if (missing.length > 0) {
|
|
const labels = missing.map((f) => f.fileLabel).join(", ");
|
|
throw new BadRequestException(
|
|
`Please upload all required documents before submitting: ${labels}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
|
|
async reviewDocument(
|
|
bookingId: string,
|
|
fileKey: string,
|
|
status: "APPROVED" | "QUERIED",
|
|
staffId: string,
|
|
note?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
// GL keeps reviewing for as long as the customer can still submit — the
|
|
// two sides share one predicate so they can never drift apart. Documents
|
|
// added after clearance was finalized still need approving/querying.
|
|
if (!clearanceDocumentsOpen(booking)) {
|
|
throw new ConflictException(
|
|
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
|
);
|
|
}
|
|
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
|
|
|
const existing =
|
|
await this.bookingsRepository.findDocumentReviews(bookingId);
|
|
const match = existing.find((r) => r.fileKey === fileKey);
|
|
const settingCode =
|
|
match?.settingCode ??
|
|
(fileKey.startsWith("custom_")
|
|
? "custom"
|
|
: (inputCode ?? outputCode ?? "custom"));
|
|
|
|
if (status === "QUERIED" && !note?.trim()) {
|
|
throw new BadRequestException(
|
|
"A note is required when querying a document",
|
|
);
|
|
}
|
|
|
|
await this.bookingsRepository.setDocumentReviewStatus(
|
|
bookingId,
|
|
settingCode,
|
|
fileKey,
|
|
status,
|
|
staffId,
|
|
note,
|
|
);
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
|
|
label:
|
|
status === 'APPROVED'
|
|
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
|
|
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
|
|
actorId: staffId,
|
|
metadata: { fileKey, note: note ?? null },
|
|
});
|
|
if (status === "QUERIED") {
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
`Document "${fileKey}" queried: ${note}`,
|
|
"CHANGES_REQUESTED",
|
|
staffId,
|
|
);
|
|
// Reopening the review phase only makes sense while clearance is still
|
|
// being decided. Querying a document that arrived afterwards must not
|
|
// drag a finalized shipment back into the GL review phase.
|
|
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
|
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
|
} as never);
|
|
}
|
|
}
|
|
|
|
const updated = await this.bookingsService.findById(bookingId);
|
|
if (status === "QUERIED") {
|
|
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
|
}
|
|
// Same reasoning as the query branch: advance the workflow only while
|
|
// clearance is still open. Approving a late-added document leaves an
|
|
// already-finalized shipment's phase exactly where it is.
|
|
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
|
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
|
if (allApproved) {
|
|
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
|
const phase =
|
|
updated.tradeDirection === 'EXPORT'
|
|
? ContractDocPhase.GlDjCollection
|
|
: ContractDocPhase.GlEtOutput;
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: phase,
|
|
} as never);
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
|
async uploadClearanceOutputDocuments(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
|
const { outputCode } = clearanceCodesForBooking(booking);
|
|
if (!outputCode) {
|
|
throw new BadRequestException(
|
|
"This booking has no customs output documents",
|
|
);
|
|
}
|
|
if (files.length === 0) {
|
|
throw new BadRequestException("No documents uploaded");
|
|
}
|
|
for (const file of files) {
|
|
await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: "bookings",
|
|
code: file.fieldname,
|
|
file,
|
|
});
|
|
}
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'OUTPUT_DOCS_UPLOADED',
|
|
label: `Uploaded customs output document(s): ${files
|
|
.map((f) => f.fieldname.replace(/_/g, ' '))
|
|
.join(', ')}`,
|
|
actorId: userId ?? null,
|
|
metadata: { fileKeys: files.map((f) => f.fieldname) },
|
|
});
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
/**
|
|
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
|
* and, for customs, the required output documents present → CLEARANCE_READY.
|
|
*/
|
|
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
if (this.isPhasedCustoms(booking)) {
|
|
throw new BadRequestException(
|
|
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
|
);
|
|
}
|
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
|
|
|
const approved = await this.isClearanceFullyApproved(booking);
|
|
if (!approved) {
|
|
throw new BadRequestException(
|
|
"All required documents must be approved before clearance can be finalized",
|
|
);
|
|
}
|
|
|
|
const { outputCode } = clearanceCodesForBooking(booking);
|
|
if (outputCode) {
|
|
const setting =
|
|
await this.fileUploadSettingsService.getByCode(outputCode);
|
|
const files = await this.filesService.findByResource(
|
|
bookingId,
|
|
"bookings",
|
|
);
|
|
const uploaded = new Set(files.map((f) => f.code));
|
|
const missing = (setting.fields ?? []).filter(
|
|
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
|
);
|
|
if (missing.length > 0) {
|
|
throw new BadRequestException(
|
|
`Upload all required customs output documents first: ${missing
|
|
.map((m) => m.fileLabel)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Intercity: there is no shipment-day request step — an approved booking
|
|
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
|
|
// pool keys on. Staff then accept it onto a passing train (that accept
|
|
// opens the pay window).
|
|
if (booking.tradeDirection === "DOMESTIC") {
|
|
const now = new Date();
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "FULLY_EXECUTED",
|
|
fullyExecutedAt: now,
|
|
lockedAt: booking.lockedAt ?? now,
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.intercityDocumentsApproved(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "CLEARANCE_READY",
|
|
} as never);
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'CLEARANCE_FINALIZED',
|
|
label: 'Finalized document approval — clearance ready',
|
|
actorId: userId ?? null,
|
|
});
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.clearanceReady(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Customer proceeds to operation once clearance is ready. They pick the
|
|
* schedule day (the train departure day) for the shipment; the request then
|
|
* sits at OPERATION_REQUEST_PENDING for the operations team to review
|
|
* (capacity, documents, route) before it enters the batch holding pool.
|
|
*
|
|
* Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED
|
|
* (resubmit after the operations team returned it for changes).
|
|
*/
|
|
async requestOperation(
|
|
bookingId: string,
|
|
scheduledDate: string,
|
|
requestedTrainScheduleId?: string | null,
|
|
opts?: {
|
|
/**
|
|
* Skip the customer day-pool departure/compatibility gate. Used ONLY by
|
|
* the shipping-line completion path, which has already validated the day
|
|
* against the line's own dedicated train (those trains are excluded from
|
|
* the customer pools, so the gate here would wrongly reject them).
|
|
*/
|
|
bypassDayPool?: boolean;
|
|
/** Acting user, recorded in the clearance history. */
|
|
userId?: string;
|
|
},
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"CLEARANCE_READY",
|
|
"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");
|
|
}
|
|
|
|
// The binding shipment day must have at least one OPEN departure on the
|
|
// route — only schedule-backed days are selectable — AND some departure
|
|
// that day must be able to physically carry this cargo type (wagon-TYPE
|
|
// gate; quantity never blocks — oversized bookings get a partial split
|
|
// offer). The batch engine assigns the specific train within that
|
|
// (route, day) pool later.
|
|
if (!opts?.bypassDayPool) {
|
|
const { hasDeparture, hasCompatible } =
|
|
await this.bookingsService.checkDayCompatibilityForBooking(
|
|
booking,
|
|
eatDay(date),
|
|
);
|
|
if (!hasDeparture) {
|
|
throw new BadRequestException(
|
|
"No departures available on the selected day for this route",
|
|
);
|
|
}
|
|
if (!hasCompatible) {
|
|
throw new BadRequestException(
|
|
"No wagon on the selected day can carry this cargo type — please choose another day",
|
|
);
|
|
}
|
|
}
|
|
|
|
// Export is FCFS and never splits — a booking must ride one train whole. So
|
|
// the free-space check belongs HERE, the moment the customer commits to a
|
|
// shipment day, not later at staff operation-accept. Blocking now stops the
|
|
// customer booking more wagons than any single export train that day can
|
|
// still carry; `exportSpaceReport` throws a 409 whose message carries the
|
|
// largest bookable leftover ("reduce to N wagons or pick another day").
|
|
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
|
// here — they get an advisory count below and the batch engine sizes them.
|
|
const isExportTrain =
|
|
booking.tradeDirection === "EXPORT" &&
|
|
!isRoadService(booking.serviceType);
|
|
// The customer's train pick only exists for export rail; it rides the
|
|
// booking through the space checks below AND is persisted so the accept /
|
|
// reserve path locks onto that train (pickExportSchedule honors it).
|
|
// Shipping-line completions (bypassDayPool) pick among the line's own
|
|
// dedicated trains — already validated by the caller, so the pick is
|
|
// persisted here the same way an export pick is. Customer import/domestic
|
|
// bookings still never carry one (the batch engine assigns their train).
|
|
const requestedId =
|
|
isExportTrain || opts?.bypassDayPool
|
|
? (requestedTrainScheduleId ?? null)
|
|
: null;
|
|
// Export rail rides the exact train the customer picked — never an
|
|
// auto-assigned one. Both portal flows (clearance + contract completion)
|
|
// surface a picker, so a missing id is an invalid submission, not a
|
|
// legitimate "let the system choose".
|
|
if (isExportTrain && !requestedId) {
|
|
throw new BadRequestException(
|
|
"Select a train for the chosen shipment day.",
|
|
);
|
|
}
|
|
const scheduledBooking = {
|
|
...booking,
|
|
scheduledDate: date,
|
|
requestedTrainScheduleId: requestedId,
|
|
} as Booking;
|
|
if (isExportTrain) {
|
|
// With export split ON the booking no longer has to ride ONE train whole:
|
|
// the largest fitting part is offered and the leftover rebooks on the next
|
|
// train. So the day is only unbookable when NO export train that day has
|
|
// any room at all — reject on the day total, not on a single-train fit.
|
|
// With the flag off this stays the strict whole-booking gate.
|
|
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
|
|
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
|
scheduledBooking,
|
|
eatDay(date),
|
|
"EXPORT",
|
|
);
|
|
const fitsRequest = requestedId
|
|
? fitting.some((f) => f.scheduleId === requestedId)
|
|
: fitting.length > 0;
|
|
if (!fitsRequest) {
|
|
throw new ConflictException(
|
|
requestedId
|
|
? "The selected train has no space left — pick another train or day."
|
|
: "No export train on this day has space left — pick another shipment day.",
|
|
);
|
|
}
|
|
} else {
|
|
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
|
}
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "OPERATION_REQUEST_PENDING",
|
|
scheduledDate: date,
|
|
requestedTrainScheduleId: requestedId,
|
|
} as never);
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'OPERATION_REQUESTED',
|
|
label: `Requested operation for shipment day ${scheduledDate}`,
|
|
actorType: 'CUSTOMER',
|
|
actorId: opts?.userId ?? null,
|
|
metadata: { scheduledDate },
|
|
});
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.operationRequestedToStaff(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Advisory availability for a shipment day the customer is considering — a
|
|
* planning hint for the day picker, computed but never enforced. For EXPORT it
|
|
* mirrors the real request-time gate: `fits` is whether a single open train
|
|
* that day can carry the WHOLE booking (export never splits), and `freeWagons`
|
|
* is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
|
|
* TOTAL room across the day's trains for the booking's wagon type (the batch
|
|
* engine may still split or defer a remainder), and `fits` is whether that
|
|
* total covers the booking. `trainsForDay` is false when no departure carries
|
|
* the leg — the day is unbookable regardless of space.
|
|
*/
|
|
/**
|
|
* Export train picker data for a shipment day the customer is choosing:
|
|
* each export train on the booking's corridor with per-wagon-type free
|
|
* space. Export rail bookings only — nothing else picks a train.
|
|
*/
|
|
async exportTrainsForBooking(
|
|
bookingId: string,
|
|
scheduledDate: string,
|
|
overrides?: {
|
|
containerTypeIds?: string[];
|
|
containerSizes?: string[];
|
|
cargoTypeId?: string;
|
|
cargoTypeCode?: string;
|
|
wagons?: number;
|
|
},
|
|
): Promise<ExportTrainOption[]> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
const date = new Date(scheduledDate);
|
|
if (Number.isNaN(date.getTime())) {
|
|
throw new BadRequestException("A valid schedule date is required");
|
|
}
|
|
if (
|
|
booking.tradeDirection !== "EXPORT" ||
|
|
isRoadService(booking.serviceType)
|
|
) {
|
|
throw new BadRequestException(
|
|
"Train selection is only available for export rail bookings",
|
|
);
|
|
}
|
|
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
|
return this.bookingBatchService.exportTrainOptionsForDay(
|
|
scheduledBooking,
|
|
eatDay(date),
|
|
overrides,
|
|
);
|
|
}
|
|
|
|
async dayAvailabilityForBooking(
|
|
bookingId: string,
|
|
scheduledDate: string,
|
|
): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
const date = new Date(scheduledDate);
|
|
if (Number.isNaN(date.getTime())) {
|
|
throw new BadRequestException("A valid schedule date is required");
|
|
}
|
|
const day = eatDay(date);
|
|
const isExportTrain =
|
|
booking.tradeDirection === "EXPORT" &&
|
|
!isRoadService(booking.serviceType);
|
|
|
|
if (isExportTrain) {
|
|
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
|
const report =
|
|
await this.bookingBatchService.exportSpaceReport(scheduledBooking);
|
|
return {
|
|
fits: report.scheduleId != null,
|
|
freeWagons: report.bestAvailable?.wagons ?? 0,
|
|
trainsForDay: report.trainsForDay && report.corridorMatched,
|
|
};
|
|
}
|
|
|
|
const { freeWagons, need, trainsForDay } =
|
|
await this.bookingBatchService.dayImportAvailability(booking, day);
|
|
return { fits: freeWagons >= need, freeWagons, trainsForDay };
|
|
}
|
|
|
|
/**
|
|
* Operations team reviews a pending operation request (capacity, documents,
|
|
* route). Two outcomes:
|
|
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
|
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
|
*
|
|
* The booking price is computed from the contract and is never adjusted here.
|
|
*/
|
|
async reviewOperationRequest(
|
|
bookingId: string,
|
|
decision: "ACCEPT" | "REQUEST_CHANGES",
|
|
actorId: string,
|
|
options: { note?: string } = {},
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
|
|
|
|
if (decision === "REQUEST_CHANGES") {
|
|
if (!options.note?.trim()) {
|
|
throw new BadRequestException(
|
|
"A note is required when requesting changes",
|
|
);
|
|
}
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
options.note,
|
|
"CHANGES_REQUESTED",
|
|
actorId,
|
|
);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "OPERATION_CHANGES_REQUESTED",
|
|
} as never);
|
|
const fresh = await this.bookingsService.findById(bookingId);
|
|
this.notifier.operationChangesRequested(fresh, options.note);
|
|
return fresh;
|
|
}
|
|
|
|
// ACCEPT — enter the batch holding pool.
|
|
return this.acceptOperationRequest(booking);
|
|
}
|
|
|
|
/**
|
|
* Move a reviewed operation request forward after Marketing accepts.
|
|
*
|
|
* - Train services enter the batch holding pool: the pool query
|
|
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
|
|
* set those and kick the day-level fill immediately instead of waiting for
|
|
* cron.
|
|
* - Road (truck) services skip the train batch entirely and wait for truck
|
|
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
|
|
*/
|
|
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
|
const now = new Date();
|
|
|
|
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
|
// booking's day still has capacity — nothing below runs and the request stays
|
|
// pending for staff to move/decline. (For a consolidated pair this is a rough
|
|
// solo pre-check; the real combined-capacity reservation happens after the
|
|
// booking is FULLY_EXECUTED, once both partners are ready.)
|
|
const isExportTrain =
|
|
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
|
if (isExportTrain) {
|
|
await this.bookingBatchService.pickExportSchedule(booking);
|
|
}
|
|
|
|
// Mint the booking's invoice (DRAFT) so the priced order carries its billing
|
|
// record from accept onward. It is deliberately NOT issued here: accepting an
|
|
// operation only puts the booking in the batch holding pool — no slot has been
|
|
// offered and no pay window exists yet. Issuing at this point made the invoice
|
|
// payable straight away (portal invoice list/detail gate on invoice status
|
|
// alone), letting a customer pay before being selected for a batch, while the
|
|
// booking page correctly still showed it as not payable. The batch engine
|
|
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
|
|
// and the real deadline are created — matching the portal's `canPay` gate.
|
|
//
|
|
// Shipping-line bookings mint NO invoice at all: they have no company row
|
|
// to bill (the invoices FK requires one) and they pay on the credit ledger
|
|
// — the charge was recorded at completion, and Finance bills a batch of
|
|
// credits later through ShippingLineCreditsService.generateInvoice.
|
|
if (booking.shippingLineCompanyId) {
|
|
this.logger.log(
|
|
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
|
|
);
|
|
} else {
|
|
const invoice =
|
|
await this.invoiceService.ensureInvoiceForBooking(booking);
|
|
this.logger.log(
|
|
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
|
);
|
|
}
|
|
// TODO: road (truck) orders are an incomplete feature — they stop at the
|
|
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
|
|
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
|
|
// skip the train batch, so they never reach `reserve` and their invoice stays
|
|
// DRAFT / unpayable. When the road flow is built, issue its invoice
|
|
// (billing.issuePayable) at whatever transition opens the road pay window.
|
|
if (isRoadService(booking.serviceType)) {
|
|
await this.bookingsRepository.update(booking.id, {
|
|
status: "ROAD_DISPATCH_PENDING",
|
|
fullyExecutedAt: now,
|
|
lockedAt: booking.lockedAt ?? now,
|
|
} as never);
|
|
const roadFresh = await this.bookingsService.findById(booking.id);
|
|
this.emitShippingLineAccepted(roadFresh);
|
|
this.notifier.operationAccepted(roadFresh);
|
|
return roadFresh;
|
|
}
|
|
|
|
await this.bookingsRepository.update(booking.id, {
|
|
status: "FULLY_EXECUTED",
|
|
fullyExecutedAt: now,
|
|
lockedAt: booking.lockedAt ?? now,
|
|
} as never);
|
|
|
|
if (isExportTrain) {
|
|
// FCFS: reserve the slot and send the payment notification immediately;
|
|
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
|
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
|
const fresh = await this.bookingsService.findById(booking.id);
|
|
try {
|
|
await this.bookingBatchService.acceptExportBooking(fresh);
|
|
} catch (err) {
|
|
// The status update above already committed. Without compensation the
|
|
// client gets an error for a booking that reads as accepted after a
|
|
// refresh — half-applied state. Put the request back so staff can retry.
|
|
await this.bookingsRepository.update(booking.id, {
|
|
status: "OPERATION_REQUEST_PENDING",
|
|
fullyExecutedAt: null,
|
|
lockedAt: booking.lockedAt ?? null,
|
|
} as never);
|
|
this.logger.warn(
|
|
`Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`,
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
|
|
// batch runs after the window closes + staff document review, never at accept
|
|
// time. (Legacy pre-migration schedules with no window phase are still served
|
|
// by the periodic legacy fill.)
|
|
//
|
|
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
|
|
// no pay window exists to wait for — accept places them straight onto
|
|
// their company's dedicated train and its wagons. Non-fatal on purpose:
|
|
// the accept has committed; an allocation hiccup leaves the booking in
|
|
// the day pool for the batch engine / staff instead of failing the accept.
|
|
if (booking.shippingLineCompanyId) {
|
|
try {
|
|
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
const trainFresh = await this.bookingsService.findById(booking.id);
|
|
this.emitShippingLineAccepted(trainFresh);
|
|
this.notifier.operationAccepted(trainFresh);
|
|
return trainFresh;
|
|
}
|
|
|
|
/**
|
|
* A shipping-line booking becomes debt at THIS moment — Operations accepted
|
|
* it — not at completion/pricing. Event, not a service call:
|
|
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
|
|
* importing its module here would close a module cycle. Emitted after the
|
|
* accept has fully committed (including the export-capacity path, which can
|
|
* still revert the status above), so a failed accept never creates debt.
|
|
*/
|
|
private emitShippingLineAccepted(booking: Booking): void {
|
|
if (!booking.shippingLineCompanyId) return;
|
|
this.events.emit("shipping_line_booking.accepted", {
|
|
bookingId: booking.id,
|
|
reference: booking.reference,
|
|
amount: Number(booking.totalAmount),
|
|
} satisfies ShippingLineBookingAcceptedPayload);
|
|
}
|
|
|
|
async enrichBookingResponse(booking: Booking): Promise<
|
|
Booking & {
|
|
latestChangeRequestNote?: string | null;
|
|
contractSummary?: string | null;
|
|
nextStep: BookingNextStep | null;
|
|
activeBatchOffer?: {
|
|
offeredWagons: number;
|
|
totalWagons: number;
|
|
offeredAmount: number;
|
|
paymentDeadline: Date;
|
|
} | null;
|
|
/** Flat list of physical container numbers on this booking (for the
|
|
* customer truck-assignment container picker). */
|
|
containerNumbers: string[];
|
|
/** The allocated train, when the booking is placed on a schedule. */
|
|
trainSchedule?: {
|
|
trainNumber: string | null;
|
|
reference: string | null;
|
|
scheduledDepartureDate: Date | null;
|
|
} | null;
|
|
}
|
|
> {
|
|
// This enrichment runs AFTER the transition has committed. A failure here
|
|
// must never 500 the response — the client would report "failed" for a
|
|
// transition that actually succeeded (visible only after a refresh).
|
|
// Degrade each fragile field to null instead.
|
|
let note: Awaited<
|
|
ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
|
|
> = null;
|
|
try {
|
|
note = await this.bookingsRepository.findLatestReviewNote(
|
|
booking.id,
|
|
"CHANGES_REQUESTED",
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
let summary: string | null = booking.contractSummary ?? null;
|
|
try {
|
|
summary =
|
|
booking.contractSummary ??
|
|
this.contractService.buildContractSummary(booking);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
let nextStep: BookingNextStep | null = null;
|
|
try {
|
|
// Bookings no longer carry an approval chain, so there is never a pending
|
|
// approval step to hint at.
|
|
nextStep = computeNextStep(booking, null);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
let activeBatchOffer: Awaited<
|
|
ReturnType<typeof this.bookingBatchService.getOpenOfferSummary>
|
|
> = null;
|
|
try {
|
|
activeBatchOffer =
|
|
booking.status === "SELECTED_FOR_BATCH"
|
|
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
|
: null;
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
// Allocated train: number + schedule reference for the detail headers
|
|
// (portal and backoffice). Degrades to null like every fragile field here.
|
|
let trainSchedule: {
|
|
trainNumber: string | null;
|
|
reference: string | null;
|
|
scheduledDepartureDate: Date | null;
|
|
} | null = null;
|
|
if (booking.trainScheduleId && this.dataSource) {
|
|
try {
|
|
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
|
|
where: { id: booking.trainScheduleId },
|
|
});
|
|
if (s) {
|
|
trainSchedule = {
|
|
trainNumber: s.trainNumber ?? null,
|
|
reference: s.reference ?? null,
|
|
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
|
|
};
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Physical container numbers entered at booking time (booking_container
|
|
// units), flattened for the customer truck-assignment container picker.
|
|
const containerNumbers = (booking.bookingContainers ?? [])
|
|
.flatMap((bc) => bc.units ?? [])
|
|
.map((unit) => unit.containerNumber)
|
|
.filter((n): n is string => Boolean(n));
|
|
|
|
return {
|
|
...booking,
|
|
latestChangeRequestNote: note?.note ?? null,
|
|
contractSummary: summary,
|
|
nextStep,
|
|
activeBatchOffer,
|
|
containerNumbers,
|
|
trainSchedule,
|
|
};
|
|
}
|
|
}
|