mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1055 lines
35 KiB
TypeScript
1055 lines
35 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
} 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 { 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 { BookingClearanceService } from '../contracts/booking-clearance.service';
|
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
|
import { ContractDocPhase } from '@edr/types';
|
|
|
|
|
|
import { Freight } from "@edr/types";
|
|
import { BookingInvoiceService } from "./booking-invoice.service";
|
|
|
|
@Injectable()
|
|
export class BookingTransitionService {
|
|
private readonly logger = new Logger(BookingTransitionService.name);
|
|
constructor(
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly ruleEngineService: RuleEngineService,
|
|
private readonly pricingService: BookingPricingService,
|
|
private readonly contractService: BookingContractService,
|
|
private readonly filesService: FilesService,
|
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
|
@Inject(forwardRef(() => BookingBatchService))
|
|
private readonly bookingBatchService: BookingBatchService,
|
|
@Inject(forwardRef(() => BookingsService))
|
|
private readonly bookingsService: BookingsService,
|
|
@Inject(forwardRef(() => BookingClearanceService))
|
|
private readonly bookingClearanceService: BookingClearanceService,
|
|
@Inject(forwardRef(() => ClearanceWorkflowService))
|
|
private readonly workflowService: ClearanceWorkflowService,
|
|
private readonly invoiceService: BookingInvoiceService,
|
|
|
|
) {}
|
|
|
|
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
|
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
|
}
|
|
|
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
|
|
|
if (Number(booking.totalAmount) <= 0) {
|
|
throw new BadRequestException(
|
|
"Generate a price before submitting (POST /bookings/:id/generate-price)",
|
|
);
|
|
}
|
|
|
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
|
this.ruleEngineService.assertNoHardBlocks({
|
|
priorityScore: computed.priorityScore,
|
|
appliedModifiers: computed.appliedModifiers,
|
|
containerWeightResults: [],
|
|
warnings: computed.warnings,
|
|
hardBlocked: computed.hardBlocked,
|
|
requiresDirectorApproval: false,
|
|
});
|
|
|
|
const stored = booking.pricingBreakdown as {
|
|
lineItems?: PriceLineItemDto[];
|
|
totalAmount?: number;
|
|
} | null;
|
|
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
|
const priorityScore =
|
|
await this.pricingService.computeSubmitPriorityScore(booking);
|
|
|
|
if (unchanged) {
|
|
await this.pricingService.createPricingSnapshots(
|
|
bookingId,
|
|
computed.usedRates,
|
|
computed.appliedModifiers,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "SUBMITTED",
|
|
priorityScore,
|
|
} as never);
|
|
|
|
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
|
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
|
// arrives. The returned status reflects that outcome.
|
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
|
updated!.id,
|
|
);
|
|
return {
|
|
bookingId: finalBooking.id,
|
|
status: finalBooking.status,
|
|
priceChanged: false,
|
|
totalAmount: Number(finalBooking.totalAmount),
|
|
currency: finalBooking.paymentCurrency,
|
|
lineItems: computed.lineItems,
|
|
};
|
|
}
|
|
|
|
const previousTotalAmount = Number(booking.totalAmount);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
totalAmount: computed.totalAmount,
|
|
priorityScore: computed.priorityScore,
|
|
pricingBreakdown: {
|
|
lineItems: computed.lineItems,
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
generatedAt: new Date().toISOString(),
|
|
},
|
|
status: "PRICE_CHANGED_PENDING_CONFIRM",
|
|
} as never);
|
|
|
|
const updatedBooking = await this.bookingsService.findById(bookingId);
|
|
return {
|
|
bookingId: updatedBooking.id,
|
|
status: updatedBooking.status,
|
|
priceChanged: true,
|
|
previousTotalAmount,
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
lineItems: computed.lineItems,
|
|
message:
|
|
"Price has changed since preview. Confirm to submit with the updated price.",
|
|
};
|
|
}
|
|
|
|
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
|
|
|
|
if (Number(booking.totalAmount) <= 0) {
|
|
throw new BadRequestException("No price to confirm");
|
|
}
|
|
|
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
|
this.ruleEngineService.assertNoHardBlocks({
|
|
priorityScore: computed.priorityScore,
|
|
appliedModifiers: computed.appliedModifiers,
|
|
containerWeightResults: [],
|
|
warnings: computed.warnings,
|
|
hardBlocked: computed.hardBlocked,
|
|
requiresDirectorApproval: false,
|
|
});
|
|
|
|
await this.pricingService.createPricingSnapshots(
|
|
bookingId,
|
|
computed.usedRates,
|
|
computed.appliedModifiers,
|
|
);
|
|
|
|
const priorityScore =
|
|
await this.pricingService.computeSubmitPriorityScore(booking);
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "SUBMITTED",
|
|
priorityScore,
|
|
totalAmount: computed.totalAmount,
|
|
pricingBreakdown: {
|
|
lineItems: computed.lineItems,
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
generatedAt: new Date().toISOString(),
|
|
},
|
|
} as never);
|
|
|
|
// Same consolidation treatment as the direct submit path.
|
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
|
updated!.id,
|
|
);
|
|
return {
|
|
bookingId: finalBooking.id,
|
|
status: finalBooking.status,
|
|
priceChanged: false,
|
|
totalAmount: Number(finalBooking.totalAmount),
|
|
currency: finalBooking.paymentCurrency,
|
|
lineItems: computed.lineItems,
|
|
message: "Booking submitted with confirmed price.",
|
|
};
|
|
}
|
|
|
|
async requestChanges(
|
|
bookingId: string,
|
|
note: string,
|
|
actorId: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["SUBMITTED"]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
note,
|
|
"CHANGES_REQUESTED",
|
|
actorId,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "CHANGES_REQUESTED",
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
/** Auto-create booking approval steps from system rules when none exist yet. */
|
|
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
|
|
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
|
|
|
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
|
freightType: booking.freightType as "CONTAINER" | "BULK",
|
|
cargoTypeId: booking.cargoTypeId,
|
|
});
|
|
}
|
|
|
|
async acceptIntake(
|
|
bookingId: string,
|
|
actorId: string,
|
|
validityDays: number,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
|
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
|
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
|
assertBookingStatus(booking, ["SUBMITTED"]);
|
|
|
|
// The backoffice must define how long the accepted contract stays valid.
|
|
// Without a window the contract has no end date and cannot be relied on, so
|
|
// accept is blocked until a positive number of days is supplied.
|
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
|
throw new BadRequestException(
|
|
"A contract validity (in days) is required to accept this booking.",
|
|
);
|
|
}
|
|
|
|
// Validity runs from the accept moment through accept + N days.
|
|
const validFrom = new Date();
|
|
const validUntil = new Date(validFrom);
|
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
|
|
|
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
|
freightType: booking.freightType as "CONTAINER" | "BULK",
|
|
cargoTypeId: booking.cargoTypeId,
|
|
});
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "PENDING_APPROVAL",
|
|
approvedByStaffId: actorId,
|
|
approvedByStaffAt: validFrom,
|
|
contractValidityDays: validityDays,
|
|
contractValidFrom: validFrom,
|
|
contractValidUntil: validUntil,
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
async staffReject(
|
|
bookingId: string,
|
|
reason: string,
|
|
actorId: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason,
|
|
"REJECTION",
|
|
actorId,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "REJECTED",
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
async approveStep(
|
|
bookingId: string,
|
|
stepId: string,
|
|
actorId: string,
|
|
requiredRole: string,
|
|
authUser?: TCurrentUser,
|
|
): Promise<Booking> {
|
|
if (authUser) {
|
|
assertCanApproveBookingStep(authUser, requiredRole);
|
|
}
|
|
|
|
let booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"PENDING_APPROVAL",
|
|
"APPROVED_PENDING_SIGNATURE",
|
|
]);
|
|
|
|
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
|
await this.ensureBookingApprovalSteps(booking);
|
|
booking = await this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
const step = await this.bookingsRepository.findApprovalStepById(
|
|
bookingId,
|
|
stepId,
|
|
);
|
|
if (!step || step.status !== "PENDING") {
|
|
throw new BadRequestException(
|
|
"Approval step not found or already actioned",
|
|
);
|
|
}
|
|
|
|
const next =
|
|
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
|
if (!next || next.id !== step.id) {
|
|
throw new BadRequestException(
|
|
"Approval steps must be completed in order",
|
|
);
|
|
}
|
|
|
|
if (step.requiredRole !== requiredRole) {
|
|
throw new BadRequestException(
|
|
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
|
);
|
|
}
|
|
|
|
const blocksRole = step.blocksRole;
|
|
if (blocksRole && blocksRole === requiredRole) {
|
|
throw new BadRequestException(
|
|
`Role ${requiredRole} is blocked for this step`,
|
|
);
|
|
}
|
|
|
|
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";
|
|
updates.approvedByStaffId = actorId;
|
|
updates.approvedByStaffAt = now;
|
|
} else if (requiredRole === "DIRECTOR") {
|
|
updates.signedByDirectorId = actorId;
|
|
updates.signedByDirectorAt = now;
|
|
} else if (requiredRole === "CEO") {
|
|
updates.signedByCeoId = actorId;
|
|
updates.signedByCeoAt = now;
|
|
}
|
|
|
|
const allDone =
|
|
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
|
if (allDone) {
|
|
updates.status = "APPROVED";
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
await this.bookingsRepository.update(bookingId, updates as never);
|
|
}
|
|
|
|
if (allDone) {
|
|
const generated = await this.contractService.generateContract(bookingId);
|
|
return this.bookingsService.findById(generated.id);
|
|
}
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async rejectStep(
|
|
bookingId: string,
|
|
stepId: string,
|
|
actorId: string,
|
|
reason: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"PENDING_APPROVAL",
|
|
"APPROVED_PENDING_SIGNATURE",
|
|
]);
|
|
|
|
const step = await this.bookingsRepository.findApprovalStepById(
|
|
bookingId,
|
|
stepId,
|
|
);
|
|
if (!step) throw new BadRequestException("Approval step not found");
|
|
|
|
await this.bookingsRepository.completeApprovalStep(
|
|
step.id,
|
|
actorId,
|
|
"REJECTED",
|
|
reason,
|
|
);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason,
|
|
"REJECTION",
|
|
actorId,
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
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"]);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "SIGNED_CUSTOMER",
|
|
customerSignedAt: new Date(),
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
async startTransit(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["PAID"]);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "IN_TRANSIT",
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
async complete(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "COMPLETED",
|
|
endDate: new Date(),
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"DRAFT",
|
|
"SUBMITTED",
|
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
|
"CHANGES_REQUESTED",
|
|
"PENDING_APPROVAL",
|
|
"CONTRACT_READY",
|
|
"OPERATION_REQUEST_PENDING",
|
|
]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason,
|
|
"REJECTION",
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "CANCELLED",
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
/**
|
|
* Customer rejects the priced booking at the confirm step. The booking becomes
|
|
* REJECTED (terminal) — the customer starts a new booking rather than editing
|
|
* this one. Only a not-yet-committed booking can be rejected this way.
|
|
*/
|
|
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"DRAFT",
|
|
"SUBMITTED",
|
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
|
"PENDING_CONSOLIDATION",
|
|
]);
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason?.trim() || "Customer rejected the price estimate.",
|
|
"REJECTION",
|
|
);
|
|
|
|
const updated = await this.bookingsRepository.update(bookingId, {
|
|
status: "REJECTED",
|
|
} as never);
|
|
return this.bookingsService.findById(updated!.id);
|
|
}
|
|
|
|
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
|
|
|
/**
|
|
* The clearance document grid for a booking: each required field from the
|
|
* resolved customer-input set (and the GL-output set for customs) with its
|
|
* uploaded file and GL review status. Drives both portals' clearance UI.
|
|
*/
|
|
async getClearanceView(bookingId: string): Promise<{
|
|
status: string;
|
|
includesCustoms: boolean;
|
|
inputCode: string | null;
|
|
outputCode: string | null;
|
|
documents: Array<{
|
|
fileKey: string;
|
|
label: string;
|
|
required: boolean;
|
|
uploadedBy: "customer" | "gl";
|
|
settingCode: string;
|
|
file: { id: string; name: string; url: string } | null;
|
|
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
|
note: string | null;
|
|
}>;
|
|
allApproved: boolean;
|
|
phase?: string | null;
|
|
milestones?: unknown[];
|
|
nextAction?: unknown;
|
|
dutyRequired?: boolean | null;
|
|
roHold?: boolean;
|
|
roHoldReason?: string | null;
|
|
vesselDepartureDate?: string | null;
|
|
operationReady?: boolean;
|
|
}> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
if (this.isPhasedGeneralCustoms(booking)) {
|
|
return this.bookingClearanceService.getClearanceView(bookingId);
|
|
}
|
|
const { inputCode, outputCode, includesCustoms } =
|
|
clearanceCodesForBooking(booking);
|
|
|
|
const files = await this.filesService.findByResource(bookingId, "bookings");
|
|
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
|
const reviews =
|
|
await this.bookingsRepository.findDocumentReviews(bookingId);
|
|
const reviewByKey = new Map(
|
|
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
|
);
|
|
|
|
const documents: Awaited<
|
|
ReturnType<BookingTransitionService["getClearanceView"]>
|
|
>["documents"] = [];
|
|
|
|
const pushSetting = async (
|
|
code: string | null,
|
|
uploadedBy: "customer" | "gl",
|
|
) => {
|
|
if (!code) return;
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(code);
|
|
} catch {
|
|
return; // setting not seeded — skip gracefully
|
|
}
|
|
for (const field of setting.fields ?? []) {
|
|
const file = fileByCode.get(field.fileKey) ?? null;
|
|
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
|
documents.push({
|
|
fileKey: field.fileKey,
|
|
label: field.fileLabel,
|
|
required: field.isRequired,
|
|
uploadedBy,
|
|
settingCode: code,
|
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
|
reviewStatus: review?.status ?? null,
|
|
note: review?.note ?? null,
|
|
});
|
|
}
|
|
};
|
|
|
|
await pushSetting(inputCode, "customer");
|
|
await pushSetting(outputCode, "gl");
|
|
|
|
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
|
for (const f of files) {
|
|
if (!f.code?.startsWith("custom_")) continue;
|
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
|
documents.push({
|
|
fileKey: f.code,
|
|
label: f.name,
|
|
required: false,
|
|
uploadedBy: "customer",
|
|
settingCode: "custom",
|
|
file: { id: f.id, name: f.name, url: f.url },
|
|
reviewStatus: review?.status ?? null,
|
|
note: review?.note ?? null,
|
|
});
|
|
}
|
|
|
|
const allApproved = await this.isClearanceFullyApproved(booking);
|
|
|
|
return {
|
|
status: booking.status,
|
|
includesCustoms,
|
|
inputCode,
|
|
outputCode,
|
|
documents,
|
|
allApproved,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* True when every REQUIRED field of the booking's customer-input clearance set
|
|
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
|
*/
|
|
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
|
const { inputCode } = clearanceCodesForBooking(booking);
|
|
if (!inputCode) return true; // no gate applies (e.g. domestic)
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
|
if (required.length === 0) return true;
|
|
const reviews = await this.bookingsRepository.findDocumentReviews(
|
|
booking.id,
|
|
);
|
|
return required.every((field) =>
|
|
reviews.some(
|
|
(r) =>
|
|
r.settingCode === inputCode &&
|
|
r.fileKey === field.fileKey &&
|
|
r.status === "APPROVED",
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Customer uploads clearance documents. Each multipart file's fieldname is the
|
|
* field's fileKey (or custom_<n> for ad-hoc). Saves FileRecords, refreshes the
|
|
* per-document review rows to PENDING, and moves the booking into review.
|
|
*/
|
|
async submitClearanceDocuments(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"AWAITING_DOCUMENTS",
|
|
"DOCUMENTS_UNDER_REVIEW",
|
|
]);
|
|
const { inputCode } = clearanceCodesForBooking(booking);
|
|
if (!inputCode) {
|
|
throw new BadRequestException(
|
|
"This booking has no document-clearance step",
|
|
);
|
|
}
|
|
if (files.length === 0) {
|
|
throw new BadRequestException("No documents uploaded");
|
|
}
|
|
|
|
// First submission (nothing in review yet): every required input field must
|
|
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
|
// is only fixing queried/pending docs, so the already-uploaded required docs
|
|
// stay in place and we don't re-gate on the full required set.
|
|
if (booking.status === "AWAITING_DOCUMENTS") {
|
|
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
|
}
|
|
|
|
for (const file of files) {
|
|
const record = await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: "bookings",
|
|
code: file.fieldname,
|
|
file,
|
|
});
|
|
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
|
const settingCode = file.fieldname.startsWith("custom_")
|
|
? "custom"
|
|
: inputCode;
|
|
await this.bookingsRepository.upsertDocumentReviewPending({
|
|
bookingId,
|
|
settingCode,
|
|
fileKey: file.fieldname,
|
|
fileRecordId: record.id,
|
|
});
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "DOCUMENTS_UNDER_REVIEW",
|
|
} as never);
|
|
|
|
if (this.isPhasedGeneralCustoms(booking)) {
|
|
await this.workflowService.onCustomerDocsUploadedForBooking(
|
|
bookingId,
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
);
|
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
|
} as never);
|
|
}
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
/**
|
|
* Guard for the first clearance submission: every required field of the
|
|
* booking's customer-input set must be covered, either by a file already on
|
|
* the booking or by one in this upload batch. Keeps the customer from starting
|
|
* review with required documents missing.
|
|
*/
|
|
private async assertRequiredInputsPresent(
|
|
bookingId: string,
|
|
inputCode: string,
|
|
files: Express.Multer.File[],
|
|
): Promise<void> {
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
|
} catch {
|
|
return; // setting not seeded — nothing to enforce
|
|
}
|
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
|
if (required.length === 0) return;
|
|
|
|
const existing = await this.filesService.findByResource(
|
|
bookingId,
|
|
"bookings",
|
|
);
|
|
const presentKeys = new Set<string>([
|
|
...existing.map((f) => f.code),
|
|
...files.map((f) => f.fieldname),
|
|
]);
|
|
|
|
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
|
if (missing.length > 0) {
|
|
const labels = missing.map((f) => f.fileLabel).join(", ");
|
|
throw new BadRequestException(
|
|
`Please upload all required documents before submitting: ${labels}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
|
|
async reviewDocument(
|
|
bookingId: string,
|
|
fileKey: string,
|
|
status: "APPROVED" | "QUERIED",
|
|
staffId: string,
|
|
note?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
|
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
|
|
|
const existing =
|
|
await this.bookingsRepository.findDocumentReviews(bookingId);
|
|
const match = existing.find((r) => r.fileKey === fileKey);
|
|
const settingCode =
|
|
match?.settingCode ??
|
|
(fileKey.startsWith("custom_")
|
|
? "custom"
|
|
: (inputCode ?? outputCode ?? "custom"));
|
|
|
|
if (status === "QUERIED" && !note?.trim()) {
|
|
throw new BadRequestException(
|
|
"A note is required when querying a document",
|
|
);
|
|
}
|
|
if (
|
|
status === 'QUERIED' &&
|
|
this.isPhasedGeneralCustoms(booking) &&
|
|
booking.preClearanceFinalizedAt
|
|
) {
|
|
throw new BadRequestException(
|
|
'Customer documents cannot be queried after pre-clearance is finalized.',
|
|
);
|
|
}
|
|
|
|
await this.bookingsRepository.setDocumentReviewStatus(
|
|
bookingId,
|
|
settingCode,
|
|
fileKey,
|
|
status,
|
|
staffId,
|
|
note,
|
|
);
|
|
if (status === "QUERIED") {
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
`Document "${fileKey}" queried: ${note}`,
|
|
"CHANGES_REQUESTED",
|
|
staffId,
|
|
);
|
|
if (this.isPhasedGeneralCustoms(booking)) {
|
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
|
} as never);
|
|
}
|
|
}
|
|
|
|
const updated = await this.bookingsService.findById(bookingId);
|
|
if (this.isPhasedGeneralCustoms(updated)) {
|
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
|
if (allApproved) {
|
|
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
|
const phase =
|
|
updated.tradeDirection === 'EXPORT'
|
|
? ContractDocPhase.GlDjCollection
|
|
: ContractDocPhase.GlEtOutput;
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: phase,
|
|
} as never);
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
|
async uploadClearanceOutputDocuments(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
|
const { outputCode } = clearanceCodesForBooking(booking);
|
|
if (!outputCode) {
|
|
throw new BadRequestException(
|
|
"This booking has no customs output documents",
|
|
);
|
|
}
|
|
if (files.length === 0) {
|
|
throw new BadRequestException("No documents uploaded");
|
|
}
|
|
for (const file of files) {
|
|
await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: "bookings",
|
|
code: file.fieldname,
|
|
file,
|
|
});
|
|
}
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
/**
|
|
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
|
* and, for customs, the required output documents present → CLEARANCE_READY.
|
|
*/
|
|
async finalizeClearance(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
if (this.isPhasedGeneralCustoms(booking)) {
|
|
throw new BadRequestException(
|
|
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
|
);
|
|
}
|
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
|
|
|
const approved = await this.isClearanceFullyApproved(booking);
|
|
if (!approved) {
|
|
throw new BadRequestException(
|
|
"All required documents must be approved before clearance can be finalized",
|
|
);
|
|
}
|
|
|
|
const { outputCode } = clearanceCodesForBooking(booking);
|
|
if (outputCode) {
|
|
const setting =
|
|
await this.fileUploadSettingsService.getByCode(outputCode);
|
|
const files = await this.filesService.findByResource(
|
|
bookingId,
|
|
"bookings",
|
|
);
|
|
const uploaded = new Set(files.map((f) => f.code));
|
|
const missing = (setting.fields ?? []).filter(
|
|
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
|
);
|
|
if (missing.length > 0) {
|
|
throw new BadRequestException(
|
|
`Upload all required customs output documents first: ${missing
|
|
.map((m) => m.fileLabel)
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "CLEARANCE_READY",
|
|
} as never);
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
/**
|
|
* Customer proceeds to operation once clearance is ready. They pick the
|
|
* schedule day (the train departure day) for the shipment; the request then
|
|
* sits at OPERATION_REQUEST_PENDING for the operations team to review
|
|
* (capacity, documents, route) before it enters the batch holding pool.
|
|
*
|
|
* Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED
|
|
* (resubmit after the operations team returned it for changes).
|
|
*/
|
|
async requestOperation(
|
|
bookingId: string,
|
|
scheduledDate: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, [
|
|
"CLEARANCE_READY",
|
|
"OPERATION_CHANGES_REQUESTED",
|
|
]);
|
|
|
|
const date = new Date(scheduledDate);
|
|
if (Number.isNaN(date.getTime())) {
|
|
throw new BadRequestException("A valid schedule date is required");
|
|
}
|
|
|
|
// The binding shipment day must have at least one OPEN departure on the
|
|
// route — only schedule-backed days are selectable. The batch engine
|
|
// assigns the specific train within that (route, day) pool later.
|
|
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
|
booking.originYardId,
|
|
booking.destinationYardId,
|
|
eatDay(date),
|
|
);
|
|
if (!hasDeparture) {
|
|
throw new BadRequestException(
|
|
"No departures available on the selected day for this route",
|
|
);
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "OPERATION_REQUEST_PENDING",
|
|
scheduledDate: date,
|
|
} as never);
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
/**
|
|
* Operations team reviews a pending operation request (capacity, documents,
|
|
* route). Two outcomes:
|
|
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
|
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
|
*
|
|
* The booking price is computed from the contract and is never adjusted here.
|
|
*/
|
|
async reviewOperationRequest(
|
|
bookingId: string,
|
|
decision: "ACCEPT" | "REQUEST_CHANGES",
|
|
actorId: string,
|
|
options: { note?: string } = {},
|
|
): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
|
|
|
|
if (decision === "REQUEST_CHANGES") {
|
|
if (!options.note?.trim()) {
|
|
throw new BadRequestException(
|
|
"A note is required when requesting changes",
|
|
);
|
|
}
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
options.note,
|
|
"CHANGES_REQUESTED",
|
|
actorId,
|
|
);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
status: "OPERATION_CHANGES_REQUESTED",
|
|
} as never);
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
// ACCEPT — enter the batch holding pool.
|
|
return this.acceptOperationRequest(booking);
|
|
}
|
|
|
|
/**
|
|
* Move a reviewed operation request forward after Marketing accepts.
|
|
*
|
|
* - Train services enter the batch holding pool: the pool query
|
|
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
|
|
* set those and kick the day-level fill immediately instead of waiting for
|
|
* cron.
|
|
* - Road (truck) services skip the train batch entirely and wait for truck
|
|
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
|
|
*/
|
|
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
|
const now = new Date();
|
|
|
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
|
this.logger.log(
|
|
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
|
);
|
|
await this.invoiceService.updateStatus(
|
|
invoice.id,
|
|
Freight.InvoiceStatus.Pending,
|
|
);
|
|
if (isRoadService(booking.serviceType)) {
|
|
await this.bookingsRepository.update(booking.id, {
|
|
status: "ROAD_DISPATCH_PENDING",
|
|
fullyExecutedAt: now,
|
|
lockedAt: booking.lockedAt ?? now,
|
|
} as never);
|
|
return this.bookingsService.findById(booking.id);
|
|
}
|
|
|
|
await this.bookingsRepository.update(booking.id, {
|
|
status: "FULLY_EXECUTED",
|
|
fullyExecutedAt: now,
|
|
lockedAt: booking.lockedAt ?? now,
|
|
} as never);
|
|
|
|
if (booking.scheduledDate) {
|
|
this.bookingBatchService.enqueueRouteDayProcessing(
|
|
booking.originYardId,
|
|
booking.destinationYardId,
|
|
eatDay(new Date(booking.scheduledDate)),
|
|
);
|
|
}
|
|
return this.bookingsService.findById(booking.id);
|
|
}
|
|
|
|
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",
|
|
);
|
|
const summary =
|
|
booking.contractSummary ??
|
|
this.contractService.buildContractSummary(booking);
|
|
const nextPending =
|
|
booking.status === "PENDING_APPROVAL" ||
|
|
booking.status === "APPROVED_PENDING_SIGNATURE"
|
|
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
|
: null;
|
|
const nextStep = computeNextStep(booking, nextPending);
|
|
return {
|
|
...booking,
|
|
latestChangeRequestNote: note?.note ?? null,
|
|
contractSummary: summary,
|
|
nextStep,
|
|
};
|
|
}
|
|
}
|