fix: the invoice status syncing with booking payment window

This commit is contained in:
ghost2023
2026-07-01 15:46:13 +03:00
parent 7e96fa65b9
commit aef868bc40
5 changed files with 762 additions and 578 deletions

View File

@@ -151,16 +151,22 @@ export class BillingService {
/** Sealed PDF invoice for any source, rendered by the shared document service. */ /** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> { async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); const invoice = await this.findById(id);
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE")); return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
} }
/** Sealed PDF receipt; available once any payment has been recorded. */ /** Sealed PDF receipt; available once any payment has been recorded. */
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) { if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException("A receipt is available only after payment is recorded."); throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
} }
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT")); return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
} }
/** Map a global invoice (+ lines) onto the source-agnostic document model. */ /** Map a global invoice (+ lines) onto the source-agnostic document model. */
@@ -177,7 +183,11 @@ export class BillingService {
if (Number(invoice.taxAmount) > 0) { if (Number(invoice.taxAmount) > 0) {
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
} }
totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true }); totals.push({
label: "Total",
amount: Number(invoice.totalAmount),
grand: true,
});
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
@@ -193,8 +203,18 @@ export class BillingService {
{ label: "Type", value: invoice.type }, { label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId }, { label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency }, { label: "Currency", value: invoice.currency },
{ label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null }, {
{ label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null }, label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
], ],
categoryHeader: "Charge type", categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({ lines: invoice.lines.map((l) => ({
@@ -303,7 +323,10 @@ export class BillingService {
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ /** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> { private nextInvoiceNumber(mg: EntityManager): Promise<string> {
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
});
} }
/** /**
@@ -351,8 +374,7 @@ export class BillingService {
input.subtotalAmount ?? input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0); lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0; const taxAmount = input.taxAmount ?? 0;
const totalAmount = const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt = const dueAt =
input.dueAt ?? input.dueAt ??
@@ -438,7 +460,9 @@ export class BillingService {
manager?: EntityManager, manager?: EntityManager,
): Promise<Invoice> { ): Promise<Invoice> {
if (!(input.amount > 0)) { if (!(input.amount > 0)) {
throw new BadRequestException("Payment amount must be greater than zero."); throw new BadRequestException(
"Payment amount must be greater than zero.",
);
} }
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
@@ -473,17 +497,13 @@ export class BillingService {
}; };
const payments = [...(invoice.payments ?? []), entry]; const payments = [...(invoice.payments ?? []), entry];
await mg.update( await mg.update(Invoice, { id: invoice.id }, {
Invoice, paidAmount,
{ id: invoice.id }, balanceAmount,
{ status,
paidAmount, payments,
balanceAmount, paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
status, } as never);
payments,
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
} as never,
);
const updated = { const updated = {
...invoice, ...invoice,
@@ -491,7 +511,7 @@ export class BillingService {
balanceAmount, balanceAmount,
status, status,
payments, payments,
paidAt: fullyPaid ? at : invoice.paidAt ?? null, paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as Invoice; } as Invoice;
if (fullyPaid) this.emitInvoiceEvent("paid", updated); if (fullyPaid) this.emitInvoiceEvent("paid", updated);
@@ -712,6 +732,20 @@ export class BillingService {
await mg.update(Invoice, { id: invoice.id }, { dueAt }); await mg.update(Invoice, { id: invoice.id }, { dueAt });
} }
async updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { status });
}
// ── Payment initiation & settlement (the gateway boundary) ─────────────────── // ── Payment initiation & settlement (the gateway boundary) ───────────────────
/** /**
@@ -738,7 +772,9 @@ export class BillingService {
): Promise<InitiateResponseDto> { ): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId); const invoice = await this.findPayable(source, sourceId);
if (!invoice) { if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); throw new NotFoundException(
`No open invoice to charge for ${source}:${sourceId}`,
);
} }
const result = await this.payment.initiate({ const result = await this.payment.initiate({

View File

@@ -109,6 +109,8 @@ export class BookingInvoiceService {
} }
} }
updateStatus = this.billing.updateStatus;
/** /**
* Advance a booking once its prepaid invoice settles — the domain side-effect * Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID * of payment, relocated out of the payment service: the booking becomes PAID

View File

@@ -3,54 +3,55 @@ import {
forwardRef, forwardRef,
Inject, Inject,
Injectable, Injectable,
Logger, } from "@nestjs/common";
} from '@nestjs/common'; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { assertCanApproveBookingStep } from "../../common/freight-permission.util";
import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { eatDay } from '../train-scheduling/batch-window.util'; import { eatDay } from "../train-scheduling/batch-window.util";
import { isRoadService } from './road.util'; import { isRoadService } from "./road.util";
import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { FilesService } from '../files/files.service'; import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from "./booking-contract.service";
import { BookingInvoiceService } from './booking-invoice.service'; import { BookingPricingService } from "./booking-pricing.service";
import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from "./bookings.repository";
import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from "./booking-status.util";
import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from "./clearance.util";
import { clearanceCodesForBooking } from './clearance.util'; import {
import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; computeNextStep,
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; type BookingNextStep,
import { PriceLineItemDto } from './dto/generate-price-response.dto'; } from "./booking-next-step.util";
import { Booking } from './entities/booking.entity'; import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import { BookingsService } from './bookings.service'; import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { Booking } from "./entities/booking.entity";
import { BookingsService } from "./bookings.service";
import { BookingInvoiceService } from "./booking-invoice.service";
import { Freight } from "@edr/types";
@Injectable() @Injectable()
export class BookingTransitionService { export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor( constructor(
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService, private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService, private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService)) @Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingsService)) @Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
) {} private readonly invoiceService: BookingInvoiceService,
) { }
async submit(bookingId: string): Promise<SubmitBookingResponseDto> { async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
if (Number(booking.totalAmount) <= 0) { if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException( throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)', "Generate a price before submitting (POST /bookings/:id/generate-price)",
); );
} }
@@ -69,7 +70,8 @@ export class BookingTransitionService {
totalAmount?: number; totalAmount?: number;
} | null; } | null;
const unchanged = this.pricingService.pricesMatch(stored, computed); const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) { if (unchanged) {
await this.pricingService.createPricingSnapshots( await this.pricingService.createPricingSnapshots(
@@ -79,7 +81,7 @@ export class BookingTransitionService {
); );
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED', status: "SUBMITTED",
priorityScore, priorityScore,
} as never); } as never);
@@ -109,7 +111,7 @@ export class BookingTransitionService {
currency: computed.currency, currency: computed.currency,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}, },
status: 'PRICE_CHANGED_PENDING_CONFIRM', status: "PRICE_CHANGED_PENDING_CONFIRM",
} as never); } as never);
const updatedBooking = await this.bookingsService.findById(bookingId); const updatedBooking = await this.bookingsService.findById(bookingId);
@@ -121,16 +123,17 @@ export class BookingTransitionService {
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
currency: computed.currency, currency: computed.currency,
lineItems: computed.lineItems, lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.', message:
"Price has changed since preview. Confirm to submit with the updated price.",
}; };
} }
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> { async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
if (Number(booking.totalAmount) <= 0) { if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm'); throw new BadRequestException("No price to confirm");
} }
const computed = await this.pricingService.computePriceForBooking(booking); const computed = await this.pricingService.computePriceForBooking(booking);
@@ -149,9 +152,10 @@ export class BookingTransitionService {
computed.appliedModifiers, computed.appliedModifiers,
); );
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED', status: "SUBMITTED",
priorityScore, priorityScore,
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
pricingBreakdown: { pricingBreakdown: {
@@ -173,7 +177,7 @@ export class BookingTransitionService {
totalAmount: Number(finalBooking.totalAmount), totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency, currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems, lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.', message: "Booking submitted with confirmed price.",
}; };
} }
@@ -183,17 +187,17 @@ export class BookingTransitionService {
actorId: string, actorId: string,
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']); assertBookingStatus(booking, ["SUBMITTED"]);
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
note, note,
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
actorId, actorId,
); );
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED', status: "CHANGES_REQUESTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
@@ -203,7 +207,7 @@ export class BookingTransitionService {
if ((booking.approvalSteps?.length ?? 0) > 0) return; if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, { await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK', freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId, cargoTypeId: booking.cargoTypeId,
}); });
} }
@@ -217,14 +221,14 @@ export class BookingTransitionService {
// Only SUBMITTED bookings are acceptable. A booking that still needs // Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED. // is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']); assertBookingStatus(booking, ["SUBMITTED"]);
// The backoffice must define how long the accepted contract stays valid. // 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 // 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. // accept is blocked until a positive number of days is supplied.
if (!Number.isInteger(validityDays) || validityDays < 1) { if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException( throw new BadRequestException(
'A contract validity (in days) is required to accept this booking.', "A contract validity (in days) is required to accept this booking.",
); );
} }
@@ -234,12 +238,12 @@ export class BookingTransitionService {
validUntil.setDate(validUntil.getDate() + validityDays); validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, { await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK', freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId, cargoTypeId: booking.cargoTypeId,
}); });
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL', status: "PENDING_APPROVAL",
approvedByStaffId: actorId, approvedByStaffId: actorId,
approvedByStaffAt: validFrom, approvedByStaffAt: validFrom,
contractValidityDays: validityDays, contractValidityDays: validityDays,
@@ -255,17 +259,17 @@ export class BookingTransitionService {
actorId: string, actorId: string,
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
reason, reason,
'REJECTION', "REJECTION",
actorId, actorId,
); );
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED', status: "REJECTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
@@ -283,8 +287,8 @@ export class BookingTransitionService {
let booking = await this.bookingsService.findById(bookingId); let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [
'PENDING_APPROVAL', "PENDING_APPROVAL",
'APPROVED_PENDING_SIGNATURE', "APPROVED_PENDING_SIGNATURE",
]); ]);
if ((booking.approvalSteps?.length ?? 0) === 0) { if ((booking.approvalSteps?.length ?? 0) === 0) {
@@ -296,14 +300,17 @@ export class BookingTransitionService {
bookingId, bookingId,
stepId, stepId,
); );
if (!step || step.status !== 'PENDING') { if (!step || step.status !== "PENDING") {
throw new BadRequestException('Approval step not found or already actioned'); throw new BadRequestException(
"Approval step not found or already actioned",
);
} }
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) { if (!next || next.id !== step.id) {
throw new BadRequestException( throw new BadRequestException(
'Approval steps must be completed in order', "Approval steps must be completed in order",
); );
} }
@@ -315,29 +322,36 @@ export class BookingTransitionService {
const blocksRole = step.blocksRole; const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) { if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
} }
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
const now = new Date(); const now = new Date();
if (requiredRole === 'LINE_STAFF') { if (requiredRole === "LINE_STAFF") {
updates.status = 'APPROVED_PENDING_SIGNATURE'; updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId; updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now; updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') { } else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId; updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now; updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') { } else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId; updates.signedByCeoId = actorId;
updates.signedByCeoAt = now; updates.signedByCeoAt = now;
} }
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) { if (allDone) {
updates.status = 'APPROVED'; updates.status = "APPROVED";
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
@@ -359,90 +373,64 @@ export class BookingTransitionService {
reason: string, reason: string,
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById( const step = await this.bookingsRepository.findApprovalStepById(
bookingId, bookingId,
stepId, stepId,
); );
if (!step) throw new BadRequestException('Approval step not found'); if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep( await this.bookingsRepository.completeApprovalStep(
step.id, step.id,
actorId, actorId,
'REJECTED', "REJECTED",
reason, reason,
); );
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
reason, reason,
'REJECTION', "REJECTION",
actorId, actorId,
); );
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED', status: "REJECTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
async customerSign(bookingId: string): Promise<Booking> { async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']); assertBookingStatus(booking, ["CONTRACT_READY"]);
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER', status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(), customerSignedAt: new Date(),
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> { async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']); assertBookingStatus(booking, ["PAID"]);
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT', status: "IN_TRANSIT",
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
async complete(bookingId: string): Promise<Booking> { async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']); assertBookingStatus(booking, ["IN_TRANSIT"]);
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED', status: "COMPLETED",
endDate: new Date(), endDate: new Date(),
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
@@ -451,22 +439,22 @@ export class BookingTransitionService {
async cancel(bookingId: string, reason: string): Promise<Booking> { async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [
'DRAFT', "DRAFT",
'SUBMITTED', "SUBMITTED",
'PRICE_CHANGED_PENDING_CONFIRM', "PRICE_CHANGED_PENDING_CONFIRM",
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
'PENDING_APPROVAL', "PENDING_APPROVAL",
'CONTRACT_READY', "CONTRACT_READY",
]); ]);
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
reason, reason,
'REJECTION', "REJECTION",
); );
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED', status: "CANCELLED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
@@ -479,20 +467,20 @@ export class BookingTransitionService {
async reject(bookingId: string, reason?: string): Promise<Booking> { async reject(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [
'DRAFT', "DRAFT",
'SUBMITTED', "SUBMITTED",
'PRICE_CHANGED_PENDING_CONFIRM', "PRICE_CHANGED_PENDING_CONFIRM",
'PENDING_CONSOLIDATION', "PENDING_CONSOLIDATION",
]); ]);
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
reason?.trim() || 'Customer rejected the price estimate.', reason?.trim() || "Customer rejected the price estimate.",
'REJECTION', "REJECTION",
); );
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED', status: "REJECTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); return this.bookingsService.findById(updated!.id);
} }
@@ -513,10 +501,10 @@ export class BookingTransitionService {
fileKey: string; fileKey: string;
label: string; label: string;
required: boolean; required: boolean;
uploadedBy: 'customer' | 'gl'; uploadedBy: "customer" | "gl";
settingCode: string; settingCode: string;
file: { id: string; name: string; url: string } | null; file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null; note: string | null;
}>; }>;
allApproved: boolean; allApproved: boolean;
@@ -525,20 +513,21 @@ export class BookingTransitionService {
const { inputCode, outputCode, includesCustoms } = const { inputCode, outputCode, includesCustoms } =
clearanceCodesForBooking(booking); clearanceCodesForBooking(booking);
const files = await this.filesService.findByResource(bookingId, 'bookings'); const files = await this.filesService.findByResource(bookingId, "bookings");
const fileByCode = new Map(files.map((f) => [f.code, f])); const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); const reviews =
await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map( const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
); );
const documents: Awaited< const documents: Awaited<
ReturnType<BookingTransitionService['getClearanceView']> ReturnType<BookingTransitionService["getClearanceView"]>
>['documents'] = []; >["documents"] = [];
const pushSetting = async ( const pushSetting = async (
code: string | null, code: string | null,
uploadedBy: 'customer' | 'gl', uploadedBy: "customer" | "gl",
) => { ) => {
if (!code) return; if (!code) return;
let setting; let setting;
@@ -556,28 +545,26 @@ export class BookingTransitionService {
required: field.isRequired, required: field.isRequired,
uploadedBy, uploadedBy,
settingCode: code, settingCode: code,
file: file file: file ? { id: file.id, name: file.name, url: file.url } : null,
? { id: file.id, name: file.name, url: file.url }
: null,
reviewStatus: review?.status ?? null, reviewStatus: review?.status ?? null,
note: review?.note ?? null, note: review?.note ?? null,
}); });
} }
}; };
await pushSetting(inputCode, 'customer'); await pushSetting(inputCode, "customer");
await pushSetting(outputCode, 'gl'); await pushSetting(outputCode, "gl");
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
for (const f of files) { for (const f of files) {
if (!f.code?.startsWith('custom_')) continue; if (!f.code?.startsWith("custom_")) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null; const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({ documents.push({
fileKey: f.code, fileKey: f.code,
label: f.name, label: f.name,
required: false, required: false,
uploadedBy: 'customer', uploadedBy: "customer",
settingCode: 'custom', settingCode: "custom",
file: { id: f.id, name: f.name, url: f.url }, file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null, reviewStatus: review?.status ?? null,
note: review?.note ?? null, note: review?.note ?? null,
@@ -611,13 +598,15 @@ export class BookingTransitionService {
} }
const required = (setting.fields ?? []).filter((f) => f.isRequired); const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true; if (required.length === 0) return true;
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); const reviews = await this.bookingsRepository.findDocumentReviews(
booking.id,
);
return required.every((field) => return required.every((field) =>
reviews.some( reviews.some(
(r) => (r) =>
r.settingCode === inputCode && r.settingCode === inputCode &&
r.fileKey === field.fileKey && r.fileKey === field.fileKey &&
r.status === 'APPROVED', r.status === "APPROVED",
), ),
); );
} }
@@ -632,33 +621,38 @@ export class BookingTransitionService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
const { inputCode } = clearanceCodesForBooking(booking); const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) { if (!inputCode) {
throw new BadRequestException('This booking has no document-clearance step'); throw new BadRequestException(
"This booking has no document-clearance step",
);
} }
if (files.length === 0) { if (files.length === 0) {
throw new BadRequestException('No documents uploaded'); throw new BadRequestException("No documents uploaded");
} }
// First submission (nothing in review yet): every required input field must // First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs // 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. // stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') { if (booking.status === "AWAITING_DOCUMENTS") {
await this.assertRequiredInputsPresent(bookingId, inputCode, files); await this.assertRequiredInputsPresent(bookingId, inputCode, files);
} }
for (const file of files) { for (const file of files) {
const record = await this.filesService.upsertByCode({ const record = await this.filesService.upsertByCode({
resourceId: bookingId, resourceId: bookingId,
resource: 'bookings', resource: "bookings",
code: file.fieldname, code: file.fieldname,
file, file,
}); });
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked. // Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith('custom_') const settingCode = file.fieldname.startsWith("custom_")
? 'custom' ? "custom"
: inputCode; : inputCode;
await this.bookingsRepository.upsertDocumentReviewPending({ await this.bookingsRepository.upsertDocumentReviewPending({
bookingId, bookingId,
@@ -669,7 +663,7 @@ export class BookingTransitionService {
} }
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: 'DOCUMENTS_UNDER_REVIEW', status: "DOCUMENTS_UNDER_REVIEW",
} as never); } as never);
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }
@@ -694,7 +688,10 @@ export class BookingTransitionService {
const required = (setting.fields ?? []).filter((f) => f.isRequired); const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return; if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings'); const existing = await this.filesService.findByResource(
bookingId,
"bookings",
);
const presentKeys = new Set<string>([ const presentKeys = new Set<string>([
...existing.map((f) => f.code), ...existing.map((f) => f.code),
...files.map((f) => f.fieldname), ...files.map((f) => f.fieldname),
@@ -702,7 +699,7 @@ export class BookingTransitionService {
const missing = required.filter((f) => !presentKeys.has(f.fileKey)); const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) { if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', '); const labels = missing.map((f) => f.fileLabel).join(", ");
throw new BadRequestException( throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`, `Please upload all required documents before submitting: ${labels}`,
); );
@@ -713,22 +710,27 @@ export class BookingTransitionService {
async reviewDocument( async reviewDocument(
bookingId: string, bookingId: string,
fileKey: string, fileKey: string,
status: 'APPROVED' | 'QUERIED', status: "APPROVED" | "QUERIED",
staffId: string, staffId: string,
note?: string, note?: string,
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { inputCode, outputCode } = clearanceCodesForBooking(booking); const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing = await this.bookingsRepository.findDocumentReviews(bookingId); const existing =
await this.bookingsRepository.findDocumentReviews(bookingId);
const match = existing.find((r) => r.fileKey === fileKey); const match = existing.find((r) => r.fileKey === fileKey);
const settingCode = const settingCode =
match?.settingCode ?? match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); (fileKey.startsWith("custom_")
? "custom"
: (inputCode ?? outputCode ?? "custom"));
if (status === 'QUERIED' && !note?.trim()) { if (status === "QUERIED" && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document'); throw new BadRequestException(
"A note is required when querying a document",
);
} }
await this.bookingsRepository.setDocumentReviewStatus( await this.bookingsRepository.setDocumentReviewStatus(
@@ -739,11 +741,11 @@ export class BookingTransitionService {
staffId, staffId,
note, note,
); );
if (status === 'QUERIED') { if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
`Document "${fileKey}" queried: ${note}`, `Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
staffId, staffId,
); );
} }
@@ -756,18 +758,20 @@ export class BookingTransitionService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { outputCode } = clearanceCodesForBooking(booking); const { outputCode } = clearanceCodesForBooking(booking);
if (!outputCode) { if (!outputCode) {
throw new BadRequestException('This booking has no customs output documents'); throw new BadRequestException(
"This booking has no customs output documents",
);
} }
if (files.length === 0) { if (files.length === 0) {
throw new BadRequestException('No documents uploaded'); throw new BadRequestException("No documents uploaded");
} }
for (const file of files) { for (const file of files) {
await this.filesService.upsertByCode({ await this.filesService.upsertByCode({
resourceId: bookingId, resourceId: bookingId,
resource: 'bookings', resource: "bookings",
code: file.fieldname, code: file.fieldname,
file, file,
}); });
@@ -781,19 +785,23 @@ export class BookingTransitionService {
*/ */
async finalizeClearance(bookingId: string): Promise<Booking> { async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const approved = await this.isClearanceFullyApproved(booking); const approved = await this.isClearanceFullyApproved(booking);
if (!approved) { if (!approved) {
throw new BadRequestException( throw new BadRequestException(
'All required documents must be approved before clearance can be finalized', "All required documents must be approved before clearance can be finalized",
); );
} }
const { outputCode } = clearanceCodesForBooking(booking); const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) { if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode); const setting =
const files = await this.filesService.findByResource(bookingId, 'bookings'); await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(
bookingId,
"bookings",
);
const uploaded = new Set(files.map((f) => f.code)); const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter( const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey), (f) => f.isRequired && !uploaded.has(f.fileKey),
@@ -802,13 +810,13 @@ export class BookingTransitionService {
throw new BadRequestException( throw new BadRequestException(
`Upload all required customs output documents first: ${missing `Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel) .map((m) => m.fileLabel)
.join(', ')}`, .join(", ")}`,
); );
} }
} }
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY', status: "CLEARANCE_READY",
} as never); } as never);
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }
@@ -827,11 +835,14 @@ export class BookingTransitionService {
scheduledDate: string, scheduledDate: string,
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); assertBookingStatus(booking, [
"CLEARANCE_READY",
"OPERATION_CHANGES_REQUESTED",
]);
const date = new Date(scheduledDate); const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
throw new BadRequestException('A valid schedule date is required'); throw new BadRequestException("A valid schedule date is required");
} }
// The binding shipment day must have at least one OPEN departure on the // The binding shipment day must have at least one OPEN departure on the
@@ -844,12 +855,12 @@ export class BookingTransitionService {
); );
if (!hasDeparture) { if (!hasDeparture) {
throw new BadRequestException( throw new BadRequestException(
'No departures available on the selected day for this route', "No departures available on the selected day for this route",
); );
} }
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING', status: "OPERATION_REQUEST_PENDING",
scheduledDate: date, scheduledDate: date,
} as never); } as never);
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
@@ -865,27 +876,27 @@ export class BookingTransitionService {
*/ */
async reviewOperationRequest( async reviewOperationRequest(
bookingId: string, bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES', decision: "ACCEPT" | "REQUEST_CHANGES",
actorId: string, actorId: string,
options: { note?: string } = {}, options: { note?: string } = {},
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
if (decision === 'REQUEST_CHANGES') { if (decision === "REQUEST_CHANGES") {
if (!options.note?.trim()) { if (!options.note?.trim()) {
throw new BadRequestException( throw new BadRequestException(
'A note is required when requesting changes', "A note is required when requesting changes",
); );
} }
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
bookingId, bookingId,
options.note, options.note,
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
actorId, actorId,
); );
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED', status: "OPERATION_CHANGES_REQUESTED",
} as never); } as never);
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }
@@ -907,9 +918,15 @@ export class BookingTransitionService {
private async acceptOperationRequest(booking: Booking): Promise<Booking> { private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date(); const now = new Date();
const invoice =
(await this.invoiceService.ensureInvoiceForBooking(booking))!;
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
);
if (isRoadService(booking.serviceType)) { if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
status: 'ROAD_DISPATCH_PENDING', status: "ROAD_DISPATCH_PENDING",
fullyExecutedAt: now, fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now, lockedAt: booking.lockedAt ?? now,
} as never); } as never);
@@ -917,7 +934,7 @@ export class BookingTransitionService {
} }
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED', status: "FULLY_EXECUTED",
fullyExecutedAt: now, fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now, lockedAt: booking.lockedAt ?? now,
} as never); } as never);
@@ -932,21 +949,23 @@ export class BookingTransitionService {
return this.bookingsService.findById(booking.id); return this.bookingsService.findById(booking.id);
} }
async enrichBookingResponse(booking: Booking): Promise<Booking & { async enrichBookingResponse(booking: Booking): Promise<
latestChangeRequestNote?: string | null; Booking & {
contractSummary?: string | null; latestChangeRequestNote?: string | null;
nextStep: BookingNextStep | null; contractSummary?: string | null;
}> { nextStep: BookingNextStep | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote( const note = await this.bookingsRepository.findLatestReviewNote(
booking.id, booking.id,
'CHANGES_REQUESTED', "CHANGES_REQUESTED",
); );
const summary = const summary =
booking.contractSummary ?? booking.contractSummary ??
this.contractService.buildContractSummary(booking); this.contractService.buildContractSummary(booking);
const nextPending = const nextPending =
booking.status === 'PENDING_APPROVAL' || booking.status === "PENDING_APPROVAL" ||
booking.status === 'APPROVED_PENDING_SIGNATURE' booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null; : null;
const nextStep = computeNextStep(booking, nextPending); const nextStep = computeNextStep(booking, nextPending);
@@ -957,4 +976,4 @@ export class BookingTransitionService {
nextStep, nextStep,
}; };
} }
} }

View File

@@ -14,12 +14,12 @@ import {
UnauthorizedException, UnauthorizedException,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from "@nestjs/common";
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from '../../common/booking-guards'; import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -27,20 +27,20 @@ import {
ApiOkResponse, ApiOkResponse,
ApiOperation, ApiOperation,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from "@nestjs/swagger";
import type { Response } from 'express'; import type { Response } from "express";
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from "./booking-contract.service";
import { BookingPricingService } from './booking-pricing.service'; import { BookingPricingService } from "./booking-pricing.service";
import { BookingTransitionService } from './booking-transition.service'; import { BookingTransitionService } from "./booking-transition.service";
import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingsService } from './bookings.service'; import { BookingsService } from "./bookings.service";
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
import { CreateBookingDto } from './dto/create-booking.dto'; import { CreateBookingDto } from "./dto/create-booking.dto";
import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
import { FilterBookingDto } from './dto/filter-booking.dto'; import { FilterBookingDto } from "./dto/filter-booking.dto";
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
import { import {
AcceptIntakeDto, AcceptIntakeDto,
ApproveStepDto, ApproveStepDto,
@@ -52,18 +52,21 @@ import {
RequestOperationDto, RequestOperationDto,
OperationReviewDto, OperationReviewDto,
StaffRejectDto, StaffRejectDto,
} from './dto/request-changes.dto'; } from "./dto/request-changes.dto";
import { ContractViewDto } from './dto/contract-view.dto'; import { ContractViewDto } from "./dto/contract-view.dto";
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from "./dto/sign-contract.dto";
import { UpdateBookingDto } from './dto/update-booking.dto'; import { UpdateBookingDto } from "./dto/update-booking.dto";
import { import {
type AuthUserPayload, type AuthUserPayload,
resolveAuthUserId, resolveAuthUserId,
} from '../../common/resolve-auth-user-id'; } from "../../common/resolve-auth-user-id";
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
@ApiTags('bookings') @ApiTags("bookings")
@Controller('bookings') @Controller("bookings")
@ApiBearerAuth() @ApiBearerAuth()
export class BookingsController { export class BookingsController {
constructor( constructor(
@@ -72,12 +75,12 @@ export class BookingsController {
private readonly pricingService: BookingPricingService, private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService, private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService, private readonly contractService: BookingContractService,
) {} ) { }
@Post() @Post()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) @ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@ApiBody({ type: CreateBookingDto }) @ApiBody({ type: CreateBookingDto })
async create( async create(
@Body() dto: CreateBookingDto, @Body() dto: CreateBookingDto,
@@ -87,15 +90,24 @@ export class BookingsController {
if (dto.isGovernment) { if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
} }
const result = await this.bookingsService.create(dto, files ?? [], user?.id); const result = await this.bookingsService.create(
dto,
files ?? [],
user?.id,
);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit. // Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.bookings.staffAccept,
);
if (isStaff && !dto.isGovernment) { if (isStaff && !dto.isGovernment) {
try { try {
await this.pricingService.generatePrice(result.booking.id); await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id); await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id); const submitted = await this.bookingsService.findById(
result.booking.id,
);
return { booking: submitted, warnings: result.warnings }; return { booking: submitted, warnings: result.warnings };
} catch { } catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
@@ -105,16 +117,16 @@ export class BookingsController {
return result; return result;
} }
@Patch(':id') @Patch(":id")
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: 'Update booking', summary: "Update booking",
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
}) })
@ApiBody({ type: UpdateBookingDto }) @ApiBody({ type: UpdateBookingDto })
update( update(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto, @Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
) { ) {
@@ -122,7 +134,7 @@ export class BookingsController {
} }
@Get() @Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' }) @ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll( async findAll(
@Query() filter: FilterBookingDto, @Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
@@ -139,7 +151,7 @@ export class BookingsController {
return this.bookingsService.findClearanceQueue(filter); return this.bookingsService.findClearanceQueue(filter);
} }
const userId = user?.id; const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required'); if (!userId) throw new UnauthorizedException("Authentication required");
const companyId = const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId); await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings). // No linked company yet → no bookings to show (avoids leaking all bookings).
@@ -165,27 +177,29 @@ export class BookingsController {
return this.bookingsService.findAll(filter, companyId); return this.bookingsService.findAll(filter, companyId);
} }
@Get('by-company/:companyId/customer-view') @Get("by-company/:companyId/customer-view")
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) @ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
findByCompanyCustomerView( findByCompanyCustomerView(
@Param('companyId', ParseUUIDPipe) companyId: string, @Param("companyId", ParseUUIDPipe) companyId: string,
) { ) {
return this.bookingsService.findCustomerBookings(companyId); return this.bookingsService.findCustomerBookings(companyId);
} }
@Get('list-summary') @Get("list-summary")
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto }) @ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) { findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter); return this.bookingsService.getListSummary(filter);
} }
@Get('my') @Get("my")
@ApiOperation({ @ApiOperation({
summary: "List the current customer's bookings ready for payment", summary: "List the current customer's bookings ready for payment",
description: description:
'Bookings owned by the authenticated user\'s company that are payable ' + "Bookings owned by the authenticated user's company that are payable " +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
}) })
findMyPayable( findMyPayable(
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
@@ -194,32 +208,32 @@ export class BookingsController {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
} }
@Get('queues/:queue') @Get("queues/:queue")
@ApiOperation({ @ApiOperation({
summary: 'List bookings for a dashboard queue', summary: "List bookings for a dashboard queue",
description: 'Queues: intake, approval, signatures, marketing, finance', description: "Queues: intake, approval, signatures, marketing, finance",
}) })
findQueue( findQueue(
@Param('queue') queue: string, @Param("queue") queue: string,
@Query() filter: FilterBookingDto, @Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string, @Query("excludeBulk") excludeBulk?: string,
) { ) {
return this.bookingsService.findQueue(queue, filter, { return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true', excludeBulk: excludeBulk === "true",
}); });
} }
@Get('reference-data') @Get("reference-data")
@ApiOperation({ summary: 'Booking form catalog' }) @ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto }) @ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> { getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData(); return this.bookingReferenceDataService.getReferenceData();
} }
@Get('by-reference/:reference') @Get("by-reference/:reference")
@ApiOperation({ summary: 'Get booking by reference' }) @ApiOperation({ summary: "Get booking by reference" })
async findByReference( async findByReference(
@Param('reference') reference: string, @Param("reference") reference: string,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
const booking = await this.bookingsService.findByReference(reference); const booking = await this.bookingsService.findByReference(reference);
@@ -233,10 +247,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Get(':id') @Get(":id")
@ApiOperation({ summary: 'Get booking by ID' }) @ApiOperation({ summary: "Get booking by ID" })
async findOne( async findOne(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
const booking = await this.bookingsService.findById(id); const booking = await this.bookingsService.findById(id);
@@ -254,15 +268,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Get(':id/tracking') @Get(":id/tracking")
@ApiOperation({ @ApiOperation({
summary: 'Shipment tracking timeline for a booking', summary: "Shipment tracking timeline for a booking",
description: description:
"Returns the booking's consignment (once dispatched) and its ordered " + "Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.', "tracking events. Scoped to the customer's own company.",
}) })
async findTracking( async findTracking(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
const booking = await this.bookingsService.findById(id); const booking = await this.bookingsService.findById(id);
@@ -276,66 +290,66 @@ export class BookingsController {
return this.bookingsService.getBookingTracking(id); return this.bookingsService.getBookingTracking(id);
} }
@Delete(':id') @Delete(":id")
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' }) @ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id); return this.bookingsService.remove(id);
} }
@Post(':id/documents') @Post(":id/documents")
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
async uploadDocuments( async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
) { ) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []); const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/generate-price') @Post(":id/generate-price")
@ApiOperation({ @ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description: description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.', "Computes and stores a price preview on the booking. Does not create rate snapshots.",
}) })
@ApiOkResponse({ type: GeneratePriceResponseDto }) @ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) { generatePrice(@Param("id", ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id); return this.pricingService.generatePrice(id);
} }
@Post(':id/submit') @Post(":id/submit")
@ApiOperation({ @ApiOperation({
summary: 'Customer submit booking', summary: "Customer submit booking",
description: description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
}) })
@ApiOkResponse({ type: SubmitBookingResponseDto }) @ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) { submit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.submit(id); return this.transitionService.submit(id);
} }
@Post(':id/confirm-submit') @Post(":id/confirm-submit")
@ApiOperation({ @ApiOperation({
summary: 'Confirm submit after price change', summary: "Confirm submit after price change",
description: description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
}) })
@ApiOkResponse({ type: SubmitBookingResponseDto }) @ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id); return this.transitionService.confirmSubmit(id);
} }
@Post(':id/reject') @Post(":id/reject")
@ApiOperation({ @ApiOperation({
summary: 'Customer reject price estimate', summary: "Customer reject price estimate",
description: description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
}) })
async reject( async reject(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto, @Body() dto: RejectBookingDto,
) { ) {
const booking = await this.transitionService.reject(id, dto.reason); const booking = await this.transitionService.reject(id, dto.reason);
@@ -344,22 +358,23 @@ export class BookingsController {
// ── Document clearance (post counter-sign) ──────────────────────────────── // ── Document clearance (post counter-sign) ────────────────────────────────
@Get(':id/clearance') @Get(":id/clearance")
@ApiOperation({ @ApiOperation({
summary: 'Document-clearance grid (required docs + upload + GL review status)', summary:
"Document-clearance grid (required docs + upload + GL review status)",
}) })
getClearance(@Param('id', ParseUUIDPipe) id: string) { getClearance(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id); return this.transitionService.getClearanceView(id);
} }
@Post(':id/clearance/documents') @Post(":id/clearance/documents")
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: 'Customer uploads clearance documents (fieldname = document key)', summary: "Customer uploads clearance documents (fieldname = document key)",
}) })
async submitClearanceDocuments( async submitClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
) { ) {
const booking = await this.transitionService.submitClearanceDocuments( const booking = await this.transitionService.submitClearanceDocuments(
@@ -369,14 +384,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/clearance/proceed') @Post(":id/clearance/proceed")
@ApiOperation({ @ApiOperation({
summary: summary:
'Customer requests operation with a schedule day ' + "Customer requests operation with a schedule day " +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
}) })
async proceedToOperation( async proceedToOperation(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto, @Body() dto: RequestOperationDto,
) { ) {
const booking = await this.transitionService.requestOperation( const booking = await this.transitionService.requestOperation(
@@ -386,15 +401,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/operation/review') @Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations) @BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ @ApiOperation({
summary: summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' + "Operations reviews an operation request: ACCEPT (→ batch pool), " +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
}) })
async reviewOperationRequest( async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto, @Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
@@ -407,11 +422,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/clearance/review') @Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) @ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument( async reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto, @Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
@@ -425,13 +442,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/clearance/output-documents') @Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes("multipart/form-data")
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
async uploadClearanceOutput( async uploadClearanceOutput(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
) { ) {
const booking = await this.transitionService.uploadClearanceOutputDocuments( const booking = await this.transitionService.uploadClearanceOutputDocuments(
@@ -441,21 +458,22 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/clearance/finalize') @Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({ @ApiOperation({
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
}) })
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id); const booking = await this.transitionService.finalizeClearance(id);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/staff/request-changes') @Post(":id/staff/request-changes")
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' }) @ApiOperation({ summary: "Staff return booking for customer updates" })
async requestChanges( async requestChanges(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto, @Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
@@ -467,14 +485,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/staff/accept') @Post(":id/staff/accept")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ @ApiOperation({
summary: summary:
'Staff accept intake → set contract validity window + start approval chain', "Staff accept intake → set contract validity window + start approval chain",
}) })
async acceptIntake( async acceptIntake(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto, @Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
@@ -486,11 +504,11 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/staff/reject') @Post(":id/staff/reject")
@BookingStaff(FREIGHT_PERMS.bookings.reject) @BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' }) @ApiOperation({ summary: "Staff final reject" })
async staffReject( async staffReject(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto, @Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
@@ -502,11 +520,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/government-expedite') @Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) @ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
async governmentExpedite( async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
const booking = await this.bookingsService.governmentExpedite( const booking = await this.bookingsService.governmentExpedite(
@@ -516,16 +536,16 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/approval-steps/:stepId/approve') @Post(":id/approval-steps/:stepId/approve")
@BookingStaff([ @BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo, FREIGHT_PERMS.bookings.approveCeo,
]) ])
@ApiOperation({ summary: 'Approve one approval step in sequence' }) @ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep( async approveStep(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string, @Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto, @Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
@@ -539,12 +559,12 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/approval-steps/:stepId/reject') @Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' }) @ApiOperation({ summary: "Reject at approval step" })
async rejectStep( async rejectStep(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string, @Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto, @Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
) { ) {
@@ -557,53 +577,53 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/contract/generate') @Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract) @BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' }) @ApiOperation({ summary: "Generate contract PDF from template" })
async generateContract(@Param('id', ParseUUIDPipe) id: string) { async generateContract(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id); const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Get(':id/contract/view') @Get(":id/contract/view")
@ApiOkResponse({ type: ContractViewDto }) @ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) @ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView( getContractView(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } }, @Request() req: { user?: { id?: string; sub?: string } },
) { ) {
const userId = req.user?.id ?? req.user?.sub; const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId); return this.contractService.getContractView(id, userId);
} }
@Get(':id/contract/document') @Get(":id/contract/document")
@ApiOperation({ summary: 'Download contract PDF' }) @ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument( async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id); const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
res.setHeader( res.setHeader(
'Content-Disposition', "Content-Disposition",
`attachment; filename="${record.name}"`, `attachment; filename="${record.name}"`,
); );
stream.pipe(res); stream.pipe(res);
} }
@Get(':id/contract') @Get(":id/contract")
@ApiOperation({ summary: 'Download contract file (alias)' }) @ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract( async downloadContract(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
return this.downloadContractDocument(id, res); return this.downloadContractDocument(id, res);
} }
@Post(':id/contract/sign') @Post(":id/contract/sign")
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) @ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract( async signContract(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto, @Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string }, @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) { ) {
@@ -615,28 +635,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Get(':id/contract/signatures') @Get(":id/contract/signatures")
@ApiOperation({ summary: 'List contract signatures' }) @ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id); return this.contractService.getSignatures(id);
} }
@Get(':id/summary') @Get(":id/summary")
@ApiOperation({ summary: 'Contract summary string for dashboard' }) @ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param('id', ParseUUIDPipe) id: string) { getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id); return this.contractService.getSummary(id);
} }
@Post(':id/customer/sign') @Post(":id/customer/sign")
@ApiOperation({ @ApiOperation({
summary: 'Customer digital signature (deprecated — use POST contract/sign)', summary: "Customer digital signature (deprecated — use POST contract/sign)",
}) })
async customerSign( async customerSign(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto, @Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string }, @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) { ) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
const booking = await this.contractService.signContract(id, payload, { const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub, signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip, ipAddress: req.ip,
@@ -644,20 +664,21 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/marketing/approve') @Post(":id/marketing/approve")
@BookingStaff(FREIGHT_PERMS.bookings.signStaff) @BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({ @ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', summary:
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
}) })
async marketingApprove( async marketingApprove(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto, @Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string }, @Request() req: { ip?: string },
) { ) {
const payload: SignContractDto = { const payload: SignContractDto = {
...dto, ...dto,
role: 'STAFF', role: "STAFF",
}; };
const booking = await this.contractService.signContract(id, payload, { const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user), signerUserId: resolveAuthUserId(user),
@@ -666,48 +687,48 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/operations/start-transit') @Post(":id/operations/start-transit")
@BookingStaff(FREIGHT_PERMS.bookings.operations) @BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' }) @ApiOperation({ summary: "Mark in transit" })
async startTransit(@Param('id', ParseUUIDPipe) id: string) { async startTransit(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id); const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/operations/complete') @Post(":id/operations/complete")
@BookingStaff(FREIGHT_PERMS.bookings.operations) @BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' }) @ApiOperation({ summary: "Mark completed" })
async complete(@Param('id', ParseUUIDPipe) id: string) { async complete(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id); const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/cancel') @Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel) @BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' }) @ApiOperation({ summary: "Cancel booking" })
async cancel( async cancel(
@Param('id', ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto, @Body() dto: CancelBookingDto,
) { ) {
const booking = await this.transitionService.cancel(id, dto.reason); const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/consolidation') @Post(":id/consolidation")
@ApiOperation({ summary: 'Request freight consolidation' }) @ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id); return this.bookingsService.requestConsolidation(id);
} }
@Delete(':id/consolidation') @Delete(":id/consolidation")
@ApiOperation({ summary: 'Remove consolidation pairing' }) @ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id); return this.bookingsService.removeConsolidation(id);
} }
@Get(':id/consolidation') @Get(":id/consolidation")
@ApiOperation({ summary: 'Get consolidation details' }) @ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id); return this.bookingsService.getConsolidationDetails(id);
} }
} }

View File

@@ -4,24 +4,24 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from "@nestjs/typeorm";
import { Cron, SchedulerRegistry } from '@nestjs/schedule'; import { Cron, SchedulerRegistry } from "@nestjs/schedule";
import { DataSource } from 'typeorm'; import { DataSource } from "typeorm";
import { Freight } from '@edr/types'; import { Freight } from "@edr/types";
import { BillingService } from '../billing/billing.service'; import { BillingService } from "../billing/billing.service";
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from "../bookings/entities/booking.entity";
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from "../bookings/bookings.repository";
import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Locomotive } from "../locomotives/entities/locomotive.entity";
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository";
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository";
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity";
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from "./booking-notifier.service";
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from "./train-scheduling.service";
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util";
import { import {
BATCH_CRON, BATCH_CRON,
BATCH_TIMEZONE, BATCH_TIMEZONE,
@@ -29,13 +29,13 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING, DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS, PAYMENT_WINDOW_MS,
} from './booking-batch.constants'; } from "./booking-batch.constants";
import { import {
bookingTrainLengthMeters, bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive, deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity, wagonTypeDimensionsFromEntity,
} from './train-capacity.util'; } from "./train-capacity.util";
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonType } from "../wagon-types/entities/wagon-type.entity";
/** A train's remaining capacity along the three physical limits the batch enforces. */ /** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity { interface Capacity {
@@ -55,12 +55,12 @@ interface RouteDayGroup {
type WagonLengths = { container: number; bulk: number }; type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState = export type BatchBoardBookingState =
| 'ALLOCATED' | "ALLOCATED"
| 'SELECTED_FOR_BATCH' | "SELECTED_FOR_BATCH"
| 'READY' | "READY"
| 'WAITING' | "WAITING"
| 'PENDING_CONTRACT' | "PENDING_CONTRACT"
| 'EXPIRED'; | "EXPIRED";
export interface BatchBoardBooking { export interface BatchBoardBooking {
id: string; id: string;
@@ -75,10 +75,10 @@ export interface BatchBoardBooking {
} }
export type BookingAllocationStatus = export type BookingAllocationStatus =
| 'NOT_ATTEMPTED' | "NOT_ATTEMPTED"
| 'ASSIGNED' | "ASSIGNED"
| 'DEFERRED' | "DEFERRED"
| 'FAILED'; | "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking { export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null; fullyExecutedAt: string | null;
@@ -116,9 +116,9 @@ export interface BatchBoardScheduleDetail {
scheduleDate: string | null; scheduleDate: string | null;
status: string; status: string;
bookingWindowStatus: string; bookingWindowStatus: string;
locomotive: BatchBoardSchedule['locomotive']; locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule['capacity']; capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule['counts']; counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[]; windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup; pendingContract: BatchWindowGroup;
allocationViolations: string[]; allocationViolations: string[];
@@ -182,7 +182,7 @@ export class BookingBatchService implements OnModuleInit {
private readonly scheduler: SchedulerRegistry, private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService, private readonly billing: BillingService,
) {} ) { }
/** On boot, reconcile OPEN route-days and re-arm settle timers. */ /** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -198,10 +198,10 @@ export class BookingBatchService implements OnModuleInit {
} }
const reserved = await this.dataSource const reserved = await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.createQueryBuilder('b') .createQueryBuilder("b")
.select('DISTINCT b.train_schedule_id', 'scheduleId') .select("DISTINCT b.train_schedule_id", "scheduleId")
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.train_schedule_id IS NOT NULL') .andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>(); .getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId); for (const { scheduleId } of reserved) this.armSettle(scheduleId);
} }
@@ -279,7 +279,7 @@ export class BookingBatchService implements OnModuleInit {
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> { private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({ const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' }, where: { bookingWindowStatus: "OPEN" },
}); });
const groups = new Map<string, RouteDayGroup>(); const groups = new Map<string, RouteDayGroup>();
for (const s of open) { for (const s of open) {
@@ -313,25 +313,29 @@ export class BookingBatchService implements OnModuleInit {
if (!booking?.trainScheduleId) return; if (!booking?.trainScheduleId) return;
const isBatchPaid = const isBatchPaid =
booking.status === 'SELECTED_FOR_BATCH' || booking.status === "SELECTED_FOR_BATCH" ||
booking.status === 'AWAITING_PAYMENT' || booking.status === "AWAITING_PAYMENT" ||
booking.status === 'PAID' || booking.status === "PAID" ||
booking.paymentStatus === 'PAID'; booking.paymentStatus === "PAID";
if (!isBatchPaid) return; if (!isBatchPaid) return;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
await this.dataSource await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); .update(bookingId, { paymentStatus: "PAID", status: "PAID" });
} else if (booking.paymentStatus !== 'PAID') { } else if (booking.paymentStatus !== "PAID") {
await this.dataSource await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' }); .update(bookingId, { paymentStatus: "PAID" });
} }
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) { if (!linked) {
await this.allocate(booking.trainScheduleId, booking, 'paid'); await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log( this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
); );
@@ -341,7 +345,7 @@ export class BookingBatchService implements OnModuleInit {
booking.trainScheduleId, booking.trainScheduleId,
); );
if (schedule && (await this.remainingWagons(schedule)) <= 0) { if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL'); await this.setWindow(booking.trainScheduleId, "FULL");
} }
const result = await this.trainSchedulingService.tryAutoWagonAllocation( const result = await this.trainSchedulingService.tryAutoWagonAllocation(
@@ -352,7 +356,11 @@ export class BookingBatchService implements OnModuleInit {
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
); );
} }
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { if (
result.issues.some(
(i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
)
) {
const issue = result.issues.find((i) => i.bookingId === bookingId); const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn( this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
@@ -367,9 +375,10 @@ export class BookingBatchService implements OnModuleInit {
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> { async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) { for (const booking of unlinked) {
await this.allocate(scheduleId, booking, 'paid'); await this.allocate(scheduleId, booking, "paid");
this.logger.log( this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
); );
@@ -378,7 +387,7 @@ export class BookingBatchService implements OnModuleInit {
// ---- cron entry point ----------------------------------------------------- // ---- cron entry point -----------------------------------------------------
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> { async runBatchFill(): Promise<void> {
const groups = await this.openRouteDayGroups(); const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
@@ -408,7 +417,7 @@ export class BookingBatchService implements OnModuleInit {
destinationStation: true, destinationStation: true,
route: true, route: true,
}, },
order: { scheduledDepartureDate: 'ASC' }, order: { scheduledDepartureDate: "ASC" },
}); });
const wagonLengths = await this.loadWagonLengths(); const wagonLengths = await this.loadWagonLengths();
@@ -416,7 +425,7 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = []; const board: BatchBoardSchedule[] = [];
for (const s of schedules) { for (const s of schedules) {
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId)); const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -428,13 +437,15 @@ export class BookingBatchService implements OnModuleInit {
id: b.id, id: b.id,
reference: b.reference ?? b.id.slice(0, 8), reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment company: b.isGovernment
? (b.governmentInstitution ?? 'Government') ? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? '—'), : (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment), isGovernment: Boolean(b.isGovernment),
wagons: need.wagons, wagons: need.wagons,
weightTons: need.weightTons, weightTons: need.weightTons,
lengthMeters: need.lengthMeters, lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)), state: this.boardState(b, linkedIds.has(b.id)),
}; };
}); });
@@ -445,11 +456,15 @@ export class BookingBatchService implements OnModuleInit {
} }
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> { async getBatchBoardDetail(
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); scheduleId: string,
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); ): Promise<BatchBoardScheduleDetail> {
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { const s =
throw new BadRequestException('Schedule is no longer active'); await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
} }
const wagonLengths = await this.loadWagonLengths(); const wagonLengths = await this.loadWagonLengths();
@@ -459,12 +474,18 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited< let allocationPreview: Awaited<
ReturnType<TrainSchedulingService['previewAllocationForSchedule']> ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>; >;
try { try {
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch { } catch {
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; allocationPreview = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
} }
const allocationByBooking = new Map( const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]), allocationPreview.issues.map((i) => [i.bookingId, i]),
@@ -477,17 +498,23 @@ export class BookingBatchService implements OnModuleInit {
id: b.id, id: b.id,
reference: b.reference ?? b.id.slice(0, 8), reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment company: b.isGovernment
? (b.governmentInstitution ?? 'Government') ? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? '—'), : (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment), isGovernment: Boolean(b.isGovernment),
wagons: need.wagons, wagons: need.wagons,
weightTons: need.weightTons, weightTons: need.weightTons,
lengthMeters: need.lengthMeters, lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)), state: this.boardState(b, linkedIds.has(b.id)),
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, fullyExecutedAt: b.fullyExecutedAt
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, ? b.fullyExecutedAt.toISOString()
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', : null,
selectedForBatchAt: b.selectedForBatchAt
? b.selectedForBatchAt.toISOString()
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null, allocationIssue: alloc?.issue ?? null,
}; };
}); });
@@ -517,11 +544,11 @@ export class BookingBatchService implements OnModuleInit {
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts(); const counts = emptyCounts();
for (const b of bookingsInWindow) { for (const b of bookingsInWindow) {
if (b.state === 'ALLOCATED') counts.allocated += 1; if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === 'READY') counts.ready += 1; else if (b.state === "READY") counts.ready += 1;
else if (b.state === 'WAITING') counts.waiting += 1; else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === 'EXPIRED') counts.expired += 1; else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1; else counts.pendingContract += 1;
} }
return counts; return counts;
@@ -529,7 +556,7 @@ export class BookingBatchService implements OnModuleInit {
const windows: BatchWindowGroup[] = []; const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) { for (const [key, bucket] of windowBuckets) {
if (key === 'pending-contract' || !bucket.window) continue; if (key === "pending-contract" || !bucket.window) continue;
const w = bucket.window; const w = bucket.window;
windows.push({ windows.push({
key: w.key, key: w.key,
@@ -542,44 +569,51 @@ export class BookingBatchService implements OnModuleInit {
bookings: bucket.items, bookings: bucket.items,
}); });
} }
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); windows.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
return { return {
scheduleId: s.id, scheduleId: s.id,
trainNumber: s.trainNumber ?? null, trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null, routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, destination:
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status, status: s.status,
bookingWindowStatus: s.bookingWindowStatus, bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco locomotive: loco
? { ? {
code: loco.code, code: loco.code,
name: loco.name ?? null, name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons), maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
} }
: null, : null,
capacity: this.computeBoardCapacity(items, loco), capacity: this.computeBoardCapacity(items, loco),
counts: { counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length, allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
ready: items.filter((i) => i.state === 'READY').length, .length,
waiting: items.filter((i) => i.state === 'WAITING').length, ready: items.filter((i) => i.state === "READY").length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, waiting: items.filter((i) => i.state === "WAITING").length,
expired: items.filter((i) => i.state === 'EXPIRED').length, pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
}, },
windows, windows,
pendingContract: { pendingContract: {
key: 'pending-contract', key: "pending-contract",
label: 'Pending contract', label: "Pending contract",
date: '', date: "",
dateLabel: '', dateLabel: "",
start: '', start: "",
end: '', end: "",
counts: countFor(pendingBookings), counts: countFor(pendingBookings),
bookings: pendingBookings, bookings: pendingBookings,
}, },
@@ -600,17 +634,21 @@ export class BookingBatchService implements OnModuleInit {
lengthMeters: number; lengthMeters: number;
}>, }>,
loco: Locomotive | null, loco: Locomotive | null,
): BatchBoardSchedule['capacity'] { ): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === 'ALLOCATED'); const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter( const committed = items.filter(
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
); );
return { return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters: allocatedLengthMeters:
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, Math.round(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, usedWeightTons:
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
}; };
} }
@@ -626,51 +664,66 @@ export class BookingBatchService implements OnModuleInit {
trainNumber: s.trainNumber ?? null, trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null, routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, destination:
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status, status: s.status,
bookingWindowStatus: s.bookingWindowStatus, bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco locomotive: loco
? { ? {
code: loco.code, code: loco.code,
name: loco.name ?? null, name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons), maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
} }
: null, : null,
capacity: this.computeBoardCapacity(items, loco), capacity: this.computeBoardCapacity(items, loco),
counts: { counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length, allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
ready: items.filter((i) => i.state === 'READY').length, .length,
waiting: items.filter((i) => i.state === 'WAITING').length, ready: items.filter((i) => i.state === "READY").length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, waiting: items.filter((i) => i.state === "WAITING").length,
expired: items.filter((i) => i.state === 'EXPIRED').length, pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
}, },
bookings: items.slice(0, 3), bookings: items.slice(0, 3),
}; };
} }
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { private boardState(
if (linked) return 'ALLOCATED'; booking: Booking,
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { linked: boolean,
return 'SELECTED_FOR_BATCH'; ): BatchBoardBookingState {
if (linked) return "ALLOCATED";
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
return "SELECTED_FOR_BATCH";
} }
if (booking.status === 'EXPIRED') return 'EXPIRED'; if (booking.status === "EXPIRED") return "EXPIRED";
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
if (booking.status === 'PAID') return 'WAITING'; return "READY";
return 'PENDING_CONTRACT'; if (booking.status === "PAID") return "WAITING";
return "PENDING_CONTRACT";
} }
// ---- core fill ------------------------------------------------------------ // ---- core fill ------------------------------------------------------------
/** Fill one schedule from its priority-ordered pool until full. */ /** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> { async fillSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule =
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
const locomotive = schedule.trainSet?.locomotive; const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) { if (!schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return; return;
} }
@@ -680,7 +733,7 @@ export class BookingBatchService implements OnModuleInit {
await this.syncScheduleMaxWagons(schedule, locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths); let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) { if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL'); await this.setWindow(scheduleId, "FULL");
return; return;
} }
@@ -692,7 +745,12 @@ export class BookingBatchService implements OnModuleInit {
if (!this.fits(need, budget)) { if (!this.fits(need, budget)) {
if (booking.isGovernment) { if (booking.isGovernment) {
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); budget = await this.preemptForGovernment(
scheduleId,
need,
budget,
wagonLengths,
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else { } else {
continue; // skip a booking that exceeds weight/length/wagons, try the next continue; // skip a booking that exceeds weight/length/wagons, try the next
@@ -700,7 +758,7 @@ export class BookingBatchService implements OnModuleInit {
} }
if (booking.isGovernment) { if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov'); await this.allocate(scheduleId, booking, "gov");
} else { } else {
await this.reserve(booking, scheduleId); await this.reserve(booking, scheduleId);
armed = true; armed = true;
@@ -709,7 +767,7 @@ export class BookingBatchService implements OnModuleInit {
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
} }
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId); if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId); void this.triggerWagonAllocation(scheduleId);
} }
@@ -735,13 +793,14 @@ export class BookingBatchService implements OnModuleInit {
const scheduleIds = bookable const scheduleIds = bookable
.filter( .filter(
(s) => (s) =>
s.bookingWindowStatus === 'OPEN' && s.bookingWindowStatus === "OPEN" &&
s.scheduleDate != null && s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day, eatDay(new Date(s.scheduleDate)) === day,
) )
.sort( .sort(
(a, b) => (a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), new Date(a.scheduleDate).getTime() -
new Date(b.scheduleDate).getTime(),
) )
.map((s) => s.id); .map((s) => s.id);
@@ -753,15 +812,22 @@ export class BookingBatchService implements OnModuleInit {
// Live per-schedule budget + arm flag, in departure order. // Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) { for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive; const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) { if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
);
continue; continue;
} }
const limits = await this.capacityLimits(locomotive, rules); const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths); const budget = await this.remainingCapacity(
schedule,
limits,
wagonLengths,
);
trains.push({ id, budget, armed: false }); trains.push({ id, budget, armed: false });
} }
if (trains.length === 0) return []; if (trains.length === 0) return [];
@@ -782,7 +848,12 @@ export class BookingBatchService implements OnModuleInit {
// Government booking fits nowhere on its own — try to preempt commercial // Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room. // on each train (earliest first) until one frees enough room.
for (const t of trains) { for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); t.budget = await this.preemptForGovernment(
t.id,
need,
t.budget,
wagonLengths,
);
if (this.fits(need, t.budget)) { if (this.fits(need, t.budget)) {
target = t; target = t;
break; break;
@@ -797,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
} }
if (booking.isGovernment) { if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov'); await this.allocate(target.id, booking, "gov");
} else { } else {
await this.reserve(booking, target.id); await this.reserve(booking, target.id);
target.armed = true; target.armed = true;
@@ -806,7 +877,7 @@ export class BookingBatchService implements OnModuleInit {
} }
for (const t of trains) { for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id); if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id); void this.triggerWagonAllocation(t.id);
} }
@@ -816,18 +887,20 @@ export class BookingBatchService implements OnModuleInit {
/** Durable settle: allocate paid / expire overdue reservations, then top up. */ /** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> { async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now(); const now = Date.now();
let anySettled = false; let anySettled = false;
for (const booking of reserved) { for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now ? booking.paymentDeadline.getTime() <= now
: false; : false;
if (paid) { if (paid) {
await this.allocate(scheduleId, booking, 'paid'); await this.allocate(scheduleId, booking, "paid");
anySettled = true; anySettled = true;
} else if (expired) { } else if (expired) {
await this.expire(booking); await this.expire(booking);
@@ -843,17 +916,19 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */ /** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> { async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId); this.removeTimeout(scheduleId);
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now(); const now = Date.now();
for (const booking of reserved) { for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now ? booking.paymentDeadline.getTime() <= now
: true; : true;
if (paid) { if (paid) {
await this.allocate(scheduleId, booking, 'paid'); await this.allocate(scheduleId, booking, "paid");
} else if (expired) { } else if (expired) {
await this.expire(booking); await this.expire(booking);
} }
@@ -865,11 +940,13 @@ export class BookingBatchService implements OnModuleInit {
} }
private triggerWagonAllocation(scheduleId: string): void { private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => void this.trainSchedulingService
this.logger.warn( .tryAutoWagonAllocation(scheduleId)
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, .catch((err) =>
), this.logger.warn(
); `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
} }
// ---- staff override actions ---------------------------------------------- // ---- staff override actions ----------------------------------------------
@@ -881,18 +958,20 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } }); .findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.trainScheduleId) { if (!booking.trainScheduleId) {
throw new BadRequestException('Booking has no target schedule to allocate to'); throw new BadRequestException(
"Booking has no target schedule to allocate to",
);
} }
await this.dataSource await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' }); .update(bookingId, { paymentStatus: "PAID" });
await this.allocate(booking.trainScheduleId, booking, 'paid'); await this.allocate(booking.trainScheduleId, booking, "paid");
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId, booking.trainScheduleId,
); );
if (schedule && (await this.remainingWagons(schedule)) <= 0) { if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL'); await this.setWindow(booking.trainScheduleId, "FULL");
} }
void this.triggerWagonAllocation(booking.trainScheduleId!); void this.triggerWagonAllocation(booking.trainScheduleId!);
} }
@@ -901,7 +980,10 @@ export class BookingBatchService implements OnModuleInit {
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
* Used for EXPIRED or full-schedule bookings — no re-approval. * Used for EXPIRED or full-schedule bookings — no re-approval.
*/ */
async moveToSchedule(bookingId: string, newScheduleId: string): Promise<void> { async moveToSchedule(
bookingId: string,
newScheduleId: string,
): Promise<void> {
const booking = await this.dataSource const booking = await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.findOne({ where: { id: bookingId } }); .findOne({ where: { id: bookingId } });
@@ -910,15 +992,20 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.dataSource const schedule = await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
.findOne({ where: { id: newScheduleId } }); .findOne({ where: { id: newScheduleId } });
if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); if (!schedule)
if (schedule.bookingWindowStatus !== 'OPEN') { throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
throw new BadRequestException('Target schedule is not accepting bookings'); if (schedule.bookingWindowStatus !== "OPEN") {
throw new BadRequestException(
"Target schedule is not accepting bookings",
);
} }
if ( if (
schedule.originStationId !== booking.originYardId || schedule.originStationId !== booking.originYardId ||
schedule.destinationStationId !== booking.destinationYardId schedule.destinationStationId !== booking.destinationYardId
) { ) {
throw new BadRequestException('Target schedule is not on the booking route'); throw new BadRequestException(
"Target schedule is not on the booking route",
);
} }
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
@@ -930,15 +1017,15 @@ export class BookingBatchService implements OnModuleInit {
); );
} }
const restoredStatus = const restoredStatus =
booking.status === 'EXPIRED' booking.status === "EXPIRED"
? booking.isGovernment ? booking.isGovernment
? 'APPROVED' ? "APPROVED"
: 'FULLY_EXECUTED' : "FULLY_EXECUTED"
: booking.status; : booking.status;
await manager.getRepository(Booking).update(bookingId, { await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId, trainScheduleId: newScheduleId,
status: restoredStatus, status: restoredStatus,
schedulingStatus: 'ELIGIBLE', schedulingStatus: "ELIGIBLE",
paymentDeadline: null, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
} as never); } as never);
@@ -952,7 +1039,8 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } }); .findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
await this.expire(booking); await this.expire(booking);
if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); if (booking.trainScheduleId)
await this.fillSchedule(booking.trainScheduleId);
} }
// ---- mutations ------------------------------------------------------------ // ---- mutations ------------------------------------------------------------
@@ -970,7 +1058,7 @@ export class BookingBatchService implements OnModuleInit {
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId, trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH', status: "SELECTED_FOR_BATCH",
selectedForBatchAt: now, selectedForBatchAt: now,
paymentDeadline: deadline, paymentDeadline: deadline,
} as never); } as never);
@@ -989,13 +1077,14 @@ export class BookingBatchService implements OnModuleInit {
private async allocate( private async allocate(
scheduleId: string, scheduleId: string,
booking: Booking, booking: Booking,
reason: 'paid' | 'gov', reason: "paid" | "gov",
): Promise<void> { ): Promise<void> {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const exists = await this.trainScheduleBookingsRepository.existsForBooking( const exists =
booking.id, await this.trainScheduleBookingsRepository.existsForBooking(
manager, booking.id,
); manager,
);
if (!exists) { if (!exists) {
await this.trainScheduleBookingsRepository.createMany( await this.trainScheduleBookingsRepository.createMany(
[{ trainScheduleId: scheduleId, bookingId: booking.id }], [{ trainScheduleId: scheduleId, bookingId: booking.id }],
@@ -1003,8 +1092,8 @@ export class BookingBatchService implements OnModuleInit {
); );
} }
await manager.getRepository(Booking).update(booking.id, { await manager.getRepository(Booking).update(booking.id, {
status: reason === 'paid' ? 'PAID' : booking.status, status: reason === "paid" ? "PAID" : booking.status,
schedulingStatus: 'SCHEDULED', schedulingStatus: "SCHEDULED",
scheduledAt: new Date(), scheduledAt: new Date(),
paymentDeadline: null, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
@@ -1022,8 +1111,8 @@ export class BookingBatchService implements OnModuleInit {
private async expire(booking: Booking): Promise<void> { private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
trainScheduleId: null, trainScheduleId: null,
status: 'EXPIRED', status: "EXPIRED",
schedulingStatus: 'ELIGIBLE', schedulingStatus: "ELIGIBLE",
paymentDeadline: null, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
} as never); } as never);
@@ -1049,7 +1138,9 @@ export class BookingBatchService implements OnModuleInit {
await this.bookingsRepository.findReservedForSchedule(scheduleId) await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment); ).filter((b) => !b.isGovernment);
const allocatedCommercial = const allocatedCommercial =
await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); await this.bookingsRepository.findAllocatedCommercialForSchedule(
scheduleId,
);
// lowest priority first; reserved are cheaper to free than allocated // lowest priority first; reserved are cheaper to free than allocated
const candidates = [...reservedCommercial, ...allocatedCommercial].sort( const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
@@ -1066,8 +1157,8 @@ export class BookingBatchService implements OnModuleInit {
manager, manager,
); );
await manager.getRepository(Booking).update(victim.id, { await manager.getRepository(Booking).update(victim.id, {
status: 'EXPIRED', status: "EXPIRED",
schedulingStatus: 'ELIGIBLE', schedulingStatus: "ELIGIBLE",
paymentDeadline: null, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
} as never); } as never);
@@ -1095,7 +1186,10 @@ export class BookingBatchService implements OnModuleInit {
(sum, c) => sum + Number(c.quantity ?? 0), (sum, c) => sum + Number(c.quantity ?? 0),
0, 0,
); );
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); return Math.max(
DEFAULT_WAGONS_PER_BOOKING,
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
);
} }
/** What one booking consumes along all three capacity axes. */ /** What one booking consumes along all three capacity axes. */
@@ -1182,7 +1276,7 @@ export class BookingBatchService implements OnModuleInit {
Array<{ lengthMeters: number; capacityTons: number }> Array<{ lengthMeters: number; capacityTons: number }>
> { > {
const types = await this.dataSource.getRepository(WagonType).find({ const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }], where: [{ code: "NW5" }, { code: "CW3" }],
}); });
if (types.length) return types.map(wagonTypeDimensionsFromEntity); if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [ return [
@@ -1193,17 +1287,23 @@ export class BookingBatchService implements OnModuleInit {
private async loadWagonLengths(): Promise<WagonLengths> { private async loadWagonLengths(): Promise<WagonLengths> {
const types = await this.dataSource.getRepository(WagonType).find({ const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }], where: [{ code: "NW5" }, { code: "CW3" }],
}); });
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
return { return {
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, container:
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, byCode.get("NW5")?.lengthMeters ??
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
}; };
} }
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> { private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); return this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.findOne({ where: {} });
} }
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
@@ -1215,7 +1315,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? []) const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking) .map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b)); .filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used = [...allocated, ...reserved].reduce<Capacity>( const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)), (acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 }, { wagons: 0, weightTons: 0, lengthMeters: 0 },
@@ -1228,7 +1330,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? []) const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking) .map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b)); .filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used = const used =
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
reserved.reduce((s, b) => s + this.wagonsFor(b), 0); reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
@@ -1237,7 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
private async setWindow( private async setWindow(
scheduleId: string, scheduleId: string,
status: 'OPEN' | 'FULL' | 'CLOSED', status: "OPEN" | "FULL" | "CLOSED",
): Promise<void> { ): Promise<void> {
await this.dataSource await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
@@ -1254,7 +1358,9 @@ export class BookingBatchService implements OnModuleInit {
this.removeTimeout(scheduleId); this.removeTimeout(scheduleId);
const handle = setTimeout(() => { const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) => void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
),
); );
}, PAYMENT_WINDOW_MS); }, PAYMENT_WINDOW_MS);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
@@ -1263,7 +1369,7 @@ export class BookingBatchService implements OnModuleInit {
private removeTimeout(scheduleId: string): void { private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId); const name = this.timeoutName(scheduleId);
try { try {
if (this.scheduler.doesExist('timeout', name)) { if (this.scheduler.doesExist("timeout", name)) {
this.scheduler.deleteTimeout(name); this.scheduler.deleteTimeout(name);
} }
} catch { } catch {