Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
Marshal 2429f6b629 implement contract cancellation feature and update contract statuses
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
2026-07-28 05:02:58 +00:00

1236 lines
46 KiB
TypeScript

import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
Optional,
} from "@nestjs/common";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } 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 { clearanceCodesForBooking } from './clearance.util';
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";
@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,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
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> {
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;
}
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",
]);
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;
}>;
allApproved: boolean;
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 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,
});
}
};
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,
label: 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,
});
}
const allApproved = await this.isClearanceFullyApproved(booking);
return {
status: booking.status,
includesCustoms,
inputCode,
outputCode,
documents,
allApproved,
};
}
/**
* 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[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
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 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,
});
}
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 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);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
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",
);
}
if (
status === 'QUERIED' &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
await this.bookingsRepository.setDocumentReviewStatus(
bookingId,
settingCode,
fileKey,
status,
staffId,
note,
);
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
`Document "${fileKey}" queried: ${note}`,
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedCustoms(booking)) {
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 ?? '');
}
if (this.isPhasedCustoms(updated)) {
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[],
): 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,
});
}
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): 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);
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,
): 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.
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 scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
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",
);
if (!fitting.length) {
throw new ConflictException(
"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,
} as never);
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.
*/
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.
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.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.)
const trainFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
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[];
}
> {
// 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}`,
);
}
// 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,
};
}
}