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

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

View File

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

View File

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