mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
refactor(bookings): replace RFQ/quotation flow with submit, staff review, approval routing, and payment stubs
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
|
||||||
|
name = 'BookingFlowRefactor1749200000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
|
||||||
|
ON freight.booking_review_note(booking_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings SET status = 'SUBMITTED'
|
||||||
|
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
|
||||||
|
UPDATE freight.bookings SET status = 'REJECTED'
|
||||||
|
WHERE status = 'QUOTATION_REJECTED';
|
||||||
|
UPDATE freight.bookings SET status = 'CANCELLED'
|
||||||
|
WHERE status = 'CANCELLED';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS locked_at,
|
||||||
|
DROP COLUMN IF EXISTS contract_summary,
|
||||||
|
DROP COLUMN IF EXISTS marketing_approved_at,
|
||||||
|
DROP COLUMN IF EXISTS marketing_approved_by_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingContractService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
buildContractSummary(booking: Booking): string {
|
||||||
|
const direction =
|
||||||
|
booking.tradeDirection === 'IMPORT'
|
||||||
|
? 'Import'
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? 'Export'
|
||||||
|
: booking.tradeDirection;
|
||||||
|
|
||||||
|
const cargo = booking.cargoType;
|
||||||
|
const isBulk = cargo?.requiresDirectorApproval;
|
||||||
|
|
||||||
|
let cargoLabel: string;
|
||||||
|
if (isBulk) {
|
||||||
|
cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
|
||||||
|
} else {
|
||||||
|
const lines =
|
||||||
|
booking.bookingContainers?.map((bc) => {
|
||||||
|
const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
|
||||||
|
return `${bc.quantity}× ${label}`;
|
||||||
|
}) ?? [];
|
||||||
|
cargoLabel =
|
||||||
|
lines.length > 0
|
||||||
|
? `Container (${lines.join(', ')})`
|
||||||
|
: `Container (${cargo?.cargoTypeName ?? 'Standard'})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummary(bookingId: string): Promise<{ summary: string }> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
const summary = booking.contractSummary ?? this.buildContractSummary(booking);
|
||||||
|
return { summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateContract(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['APPROVED']);
|
||||||
|
|
||||||
|
const summary = this.buildContractSummary(booking);
|
||||||
|
const body = [
|
||||||
|
'FREIGHT CONTRACT (STUB)',
|
||||||
|
`Reference: ${booking.reference}`,
|
||||||
|
summary,
|
||||||
|
`Total: ${booking.totalAmount} ${booking.paymentCurrency}`,
|
||||||
|
`Trade: ${booking.tradeDirection}`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const buffer = Buffer.from(body, 'utf-8');
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'contract',
|
||||||
|
originalname: `contract-${booking.reference}.txt`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'text/plain',
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.filesService.upload({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'contract',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'CONTRACT_READY',
|
||||||
|
contractSummary: summary,
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamContract(bookingId: string) {
|
||||||
|
const record = await this.filesService.findByCode(
|
||||||
|
bookingId,
|
||||||
|
'bookings',
|
||||||
|
'contract',
|
||||||
|
);
|
||||||
|
return this.filesService.streamById(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
|
||||||
|
const PROOF_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingPaymentService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generatePnr(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||||
|
|
||||||
|
if (booking.paymentCurrency !== 'ETB') {
|
||||||
|
throw new BadRequestException('PNR generation is only for ETB payers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PNR_GENERATED',
|
||||||
|
pnrCode,
|
||||||
|
paymentStatus: 'PNR_GENERATED',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPaymentProof(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||||
|
|
||||||
|
if (booking.paymentCurrency !== 'USD') {
|
||||||
|
throw new BadRequestException('Payment proof upload is only for USD payers');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateProofFile(file);
|
||||||
|
|
||||||
|
await this.filesService.upload({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'payment_proof',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
paymentStatus: 'VERIFICATION_IN_PROGRESS',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPayment(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleBankCallback(pnrCode: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'PNR_GENERATED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(booking.id, {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentRequestLetter(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
const body = [
|
||||||
|
'PAYMENT REQUEST LETTER (STUB)',
|
||||||
|
`Reference: ${booking.reference}`,
|
||||||
|
`Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
|
||||||
|
'Pay at your bank and upload stamped proof.',
|
||||||
|
].join('\n');
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(body, 'utf-8'),
|
||||||
|
filename: `payment-request-${booking.reference}.txt`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateProofFile(file: Express.Multer.File): void {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('Payment proof file is required');
|
||||||
|
}
|
||||||
|
if (file.size > PROOF_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('Payment proof must be 5MB or less');
|
||||||
|
}
|
||||||
|
if (!PROOF_MIMES.includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findById(id);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
|
import {
|
||||||
|
IRatesRepository,
|
||||||
|
RATES_REPOSITORY,
|
||||||
|
} from '../rule-engine/interfaces/rates.repository.interface';
|
||||||
|
import {
|
||||||
|
IServiceTypesRepository,
|
||||||
|
SERVICE_TYPES_REPOSITORY,
|
||||||
|
} from '../rule-engine/interfaces/service-types.repository.interface';
|
||||||
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
|
import {
|
||||||
|
AppliedCargoModifier,
|
||||||
|
BookingEvaluationInput,
|
||||||
|
RuleEngineService,
|
||||||
|
} from '../rule-engine/rule-engine.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingPricingService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
|
@Inject(RATES_REPOSITORY)
|
||||||
|
private readonly ratesRepo: IRatesRepository,
|
||||||
|
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||||
|
private readonly serviceTypesRepo: IServiceTypesRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['DRAFT']);
|
||||||
|
|
||||||
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||||
|
|
||||||
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||||
|
for (const line of baseLines) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
|
const item: PriceLineItemDto = {
|
||||||
|
code: mod.surchargeTypeCode,
|
||||||
|
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||||
|
amount: mod.calculatedAmount,
|
||||||
|
currency: mod.currency,
|
||||||
|
};
|
||||||
|
lineItems.push(item);
|
||||||
|
total += mod.calculatedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
totalAmount: total,
|
||||||
|
priorityScore: ruleResult.priorityScore,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
totalAmount: total,
|
||||||
|
currency: booking.paymentCurrency,
|
||||||
|
lineItems,
|
||||||
|
warnings: ruleResult.warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||||
|
const containers = await Promise.all(
|
||||||
|
(booking.bookingContainers ?? []).map(async (bc) => {
|
||||||
|
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||||
|
const vgm = Number(bc.vgmPerUnitTons);
|
||||||
|
const qty = bc.quantity;
|
||||||
|
return {
|
||||||
|
containerTypeId: bc.containerTypeId,
|
||||||
|
quantity: qty,
|
||||||
|
vgmPerUnitTons: vgm,
|
||||||
|
totalVgmTons: qty * vgm,
|
||||||
|
isReefer: ct.isReefer,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cargoTypeId: booking.cargoTypeId,
|
||||||
|
serviceTypeId: booking.serviceTypeId,
|
||||||
|
paymentCurrency: booking.paymentCurrency,
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
isHazardous: booking.isHazardous,
|
||||||
|
allowConsolidation: booking.allowConsolidation,
|
||||||
|
shippingLineId: booking.shippingLineId,
|
||||||
|
containers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute priority on submit (USD + service tier). */
|
||||||
|
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
|
||||||
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
let score = ruleResult.priorityScore;
|
||||||
|
|
||||||
|
const serviceType = await this.serviceTypesRepo.findById(booking.serviceTypeId);
|
||||||
|
if (booking.paymentCurrency === 'USD' && serviceType) {
|
||||||
|
const code = (serviceType.code ?? '').toUpperCase();
|
||||||
|
const hasForwarding =
|
||||||
|
serviceType.includesFirstMile ||
|
||||||
|
serviceType.includesLastMile ||
|
||||||
|
code.includes('FORWARD') ||
|
||||||
|
code.includes('Y');
|
||||||
|
const railOnly = code.includes('RAIL') && !hasForwarding;
|
||||||
|
|
||||||
|
if (hasForwarding) score += 1000;
|
||||||
|
else if (railOnly || code.includes('X')) score += 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async computeBaseRailLines(
|
||||||
|
booking: Booking,
|
||||||
|
evalInput: BookingEvaluationInput,
|
||||||
|
): Promise<PriceLineItemDto[]> {
|
||||||
|
const liveRates = await this.ratesRepo.findLiveRates();
|
||||||
|
const currency = booking.paymentCurrency;
|
||||||
|
const isBulk = booking.cargoType?.requiresDirectorApproval ?? false;
|
||||||
|
|
||||||
|
const rateType =
|
||||||
|
booking.tradeDirection === 'IMPORT'
|
||||||
|
? isBulk
|
||||||
|
? 'BULK_IMPORT'
|
||||||
|
: 'CONTAINER_IMPORT'
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? isBulk
|
||||||
|
? 'BULK_EXPORT'
|
||||||
|
: 'CONTAINER_EXPORT'
|
||||||
|
: 'INTERCITY_CONTAINER';
|
||||||
|
|
||||||
|
const lines: PriceLineItemDto[] = [];
|
||||||
|
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||||
|
|
||||||
|
for (const container of evalInput.containers) {
|
||||||
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||||
|
if (!rate) continue;
|
||||||
|
|
||||||
|
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||||
|
lines.push({
|
||||||
|
code: rateType,
|
||||||
|
description: `Base rail (${rateType})`,
|
||||||
|
amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
const fallback = liveRates.find(
|
||||||
|
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||||
|
);
|
||||||
|
if (fallback) {
|
||||||
|
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||||
|
lines.push({
|
||||||
|
code: rateType,
|
||||||
|
description: `Base rail (${rateType})`,
|
||||||
|
amount,
|
||||||
|
currency: fallback.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRate(
|
||||||
|
rates: Rate[],
|
||||||
|
rateType: string,
|
||||||
|
containerTypeId: string,
|
||||||
|
currency: string,
|
||||||
|
): Rate | undefined {
|
||||||
|
return (
|
||||||
|
rates.find(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === rateType &&
|
||||||
|
r.currency === currency &&
|
||||||
|
r.containerTypeId === containerTypeId,
|
||||||
|
) ??
|
||||||
|
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||||
|
const value = Number(rate.rateValue);
|
||||||
|
switch (rate.rateUnit) {
|
||||||
|
case 'PER_CONTAINER':
|
||||||
|
return value * quantity;
|
||||||
|
case 'PER_WAGON':
|
||||||
|
return value * wagonCount;
|
||||||
|
case 'PER_TON':
|
||||||
|
return value * quantity;
|
||||||
|
case 'FLAT':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return value * quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistPriceRun(
|
||||||
|
bookingId: string,
|
||||||
|
modifiers: AppliedCargoModifier[],
|
||||||
|
_total: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
||||||
|
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||||
|
|
||||||
|
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
||||||
|
const rows = modifiers
|
||||||
|
.map((m) => {
|
||||||
|
const snapshotId = snapshotByRateId.get(m.rateId);
|
||||||
|
if (!snapshotId) return null;
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
surchargeTypeId: m.surchargeTypeId,
|
||||||
|
triggerValue: m.triggerValue,
|
||||||
|
calculatedAmount: m.calculatedAmount,
|
||||||
|
rateSnapshotId: snapshotId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.bookingsRepository.createCargoModifiers(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
export function assertBookingStatus(booking: Booking, allowed: string[]): void {
|
||||||
|
if (!allowed.includes(booking.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RuleEngineService } from '../rule-engine/rule-engine.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 { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingTransitionService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
|
private readonly pricingService: BookingPricingService,
|
||||||
|
private readonly contractService: BookingContractService,
|
||||||
|
@Inject(forwardRef(() => BookingsService))
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async submit(bookingId: string): Promise<Booking> {
|
||||||
|
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 priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||||
|
await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
priorityScore,
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptIntake(bookingId: string, actorId?: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['SUBMITTED']);
|
||||||
|
|
||||||
|
await this.ruleEngineService.instantiateApprovalSteps(
|
||||||
|
bookingId,
|
||||||
|
booking.cargoTypeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PENDING_APPROVAL',
|
||||||
|
approvedByStaffId: actorId ?? booking.approvedByStaffId,
|
||||||
|
approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt,
|
||||||
|
} 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,
|
||||||
|
): 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 || 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.approvalRule?.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 ?? null,
|
||||||
|
marketingApprovedAt: new Date(),
|
||||||
|
lockedAt: 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',
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
'PENDING_APPROVAL',
|
||||||
|
'CONTRACT_READY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
reason,
|
||||||
|
'REJECTION',
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||||
|
latestChangeRequestNote?: string | null;
|
||||||
|
contractSummary?: string | null;
|
||||||
|
}> {
|
||||||
|
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
|
booking.id,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
);
|
||||||
|
const summary =
|
||||||
|
booking.contractSummary ??
|
||||||
|
this.contractService.buildContractSummary(booking);
|
||||||
|
return {
|
||||||
|
...booking,
|
||||||
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
|
contractSummary: summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Header,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -10,10 +11,12 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from '@nestjs/common';
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
@@ -21,181 +24,332 @@ import {
|
|||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingsService } from "./bookings.service";
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
import { BookingsService } from './bookings.service';
|
||||||
import { UpdateStatusDto } from "./dto/update-status.dto";
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
|
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||||
|
import {
|
||||||
|
ApproveStepDto,
|
||||||
|
CancelBookingDto,
|
||||||
|
RejectStepDto,
|
||||||
|
MarketingApproveDto,
|
||||||
|
RequestChangesDto,
|
||||||
|
StaffAcceptDto,
|
||||||
|
StaffRejectDto,
|
||||||
|
} from './dto/request-changes.dto';
|
||||||
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
|
|
||||||
@ApiTags("bookings")
|
@ApiTags('bookings')
|
||||||
@Controller("bookings")
|
@Controller('bookings')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BookingsController {
|
export class BookingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
||||||
|
private readonly pricingService: BookingPricingService,
|
||||||
|
private readonly transitionService: BookingTransitionService,
|
||||||
|
private readonly contractService: BookingContractService,
|
||||||
|
private readonly paymentService: BookingPaymentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||||
summary: "Create a new freight booking",
|
@ApiBody({ type: CreateBookingDto })
|
||||||
description:
|
|
||||||
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
|
|
||||||
"Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
|
|
||||||
})
|
|
||||||
@ApiBody({
|
|
||||||
description:
|
|
||||||
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
|
|
||||||
"Each uploaded file is saved as a row in the files table (resource=bookings).",
|
|
||||||
type: CreateBookingDto,
|
|
||||||
})
|
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Request() req: any,
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
) {
|
) {
|
||||||
console.log(
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
"[BookingsController] Files received:",
|
|
||||||
files?.length,
|
|
||||||
files?.map((f) => ({
|
|
||||||
fieldname: f.fieldname,
|
|
||||||
originalname: f.originalname,
|
|
||||||
size: f.size,
|
|
||||||
mimetype: f.mimetype,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const userId: string | undefined = req.user?.id ?? req.user?.sub;
|
|
||||||
return this.bookingsService.create(dto, files ?? [], userId);
|
return this.bookingsService.create(dto, files ?? [], userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Update draft booking (multipart/form-data) ─────────────────────
|
@Patch(':id')
|
||||||
@Patch(":id")
|
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Update a draft booking",
|
summary: 'Update booking',
|
||||||
description:
|
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||||
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
|
||||||
})
|
})
|
||||||
@ApiBody({ type: UpdateBookingDto })
|
@ApiBody({ type: UpdateBookingDto })
|
||||||
update(
|
update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateBookingDto,
|
@Body() dto: UpdateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
return this.bookingsService.update(id, dto, files ?? []);
|
return this.bookingsService.update(id, dto, files ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. List bookings (paginated + filtered) ───────────────────────────
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||||
summary: "List freight bookings (paginated)",
|
|
||||||
description:
|
|
||||||
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
|
|
||||||
"paymentCurrency, allowConsolidation, consolidationPaired. " +
|
|
||||||
"Sort by createdAt or priorityScore.",
|
|
||||||
})
|
|
||||||
findAll(@Query() filter: FilterBookingDto) {
|
findAll(@Query() filter: FilterBookingDto) {
|
||||||
return this.bookingsService.findAll(filter);
|
return this.bookingsService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Booking form catalog (must be before :id) ─────────────────────────
|
@Get('queues/:queue')
|
||||||
@Get("reference-data")
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Booking form catalog",
|
summary: 'List bookings for a dashboard queue',
|
||||||
description:
|
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||||
"Returns yards, container types (grouped by size), service types, shipping lines, " +
|
|
||||||
"and hierarchical cargo types for the booking UI in a single payload.",
|
|
||||||
})
|
})
|
||||||
|
findQueue(
|
||||||
|
@Param('queue') queue: string,
|
||||||
|
@Query() filter: FilterBookingDto,
|
||||||
|
@Query('excludeBulk') excludeBulk?: string,
|
||||||
|
) {
|
||||||
|
return this.bookingsService.findQueue(queue, filter, {
|
||||||
|
excludeBulk: excludeBulk === 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reference-data')
|
||||||
|
@ApiOperation({ summary: 'Booking form catalog' })
|
||||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
return this.bookingReferenceDataService.getReferenceData();
|
return this.bookingReferenceDataService.getReferenceData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
|
@Get('by-reference/:reference')
|
||||||
@Get("by-reference/:reference")
|
@ApiOperation({ summary: 'Get booking by reference' })
|
||||||
@ApiOperation({
|
async findByReference(@Param('reference') reference: string) {
|
||||||
summary: "Get a freight booking by reference number",
|
const booking = await this.bookingsService.findByReference(reference);
|
||||||
description: "Lookup booking by its human-readable reference string.",
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
})
|
|
||||||
findByReference(@Param("reference") reference: string) {
|
|
||||||
return this.bookingsService.findByReference(reference);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Get single booking by ID ───────────────────────────────────────
|
@Get(':id')
|
||||||
@Get(":id")
|
@ApiOperation({ summary: 'Get booking by ID' })
|
||||||
@ApiOperation({ summary: "Get a freight booking by ID" })
|
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
const booking = await this.bookingsService.findById(id);
|
||||||
return this.bookingsService.findById(id);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
|
@Delete(':id')
|
||||||
@Delete(":id")
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||||
summary: "Soft-delete a freight booking",
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
description: "Only DRAFT bookings can be deleted.",
|
|
||||||
})
|
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.bookingsService.remove(id);
|
return this.bookingsService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 7. Unified status transition ──────────────────────────────────────
|
@Post(':id/generate-price')
|
||||||
@Patch(":id/status")
|
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||||
@ApiOperation({
|
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||||
summary: "Transition booking status",
|
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
description:
|
return this.pricingService.generatePrice(id);
|
||||||
"Unified endpoint for all status transitions. Actions: " +
|
|
||||||
"SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
|
|
||||||
"Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
|
|
||||||
"Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
|
|
||||||
})
|
|
||||||
updateStatus(
|
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: UpdateStatusDto,
|
|
||||||
) {
|
|
||||||
return this.bookingsService.updateStatus(id, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 8. Request or auto-pair consolidation ─────────────────────────────
|
@Post(':id/submit')
|
||||||
@Post(":id/consolidation")
|
@ApiOperation({ summary: 'Customer submit booking' })
|
||||||
@ApiOperation({
|
async submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
summary: "Request freight consolidation",
|
const booking = await this.transitionService.submit(id);
|
||||||
description:
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
"Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
|
}
|
||||||
"(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
|
|
||||||
})
|
@Post(':id/staff/request-changes')
|
||||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||||
|
async requestChanges(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RequestChangesDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.requestChanges(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
dto.actorId,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/accept')
|
||||||
|
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||||
|
async acceptIntake(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: StaffAcceptDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.acceptIntake(id, dto.actorId);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/reject')
|
||||||
|
@ApiOperation({ summary: 'Staff final reject' })
|
||||||
|
async staffReject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: StaffRejectDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.staffReject(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
dto.actorId,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
|
async approveStep(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
|
@Body() dto: ApproveStepDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.approveStep(
|
||||||
|
id,
|
||||||
|
stepId,
|
||||||
|
dto.actorId,
|
||||||
|
dto.requiredRole,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approval-steps/:stepId/reject')
|
||||||
|
@ApiOperation({ summary: 'Reject at approval step' })
|
||||||
|
async rejectStep(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
|
@Body() dto: RejectStepDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.rejectStep(
|
||||||
|
id,
|
||||||
|
stepId,
|
||||||
|
dto.actorId,
|
||||||
|
dto.reason,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contract/generate')
|
||||||
|
@ApiOperation({ summary: 'Generate contract document' })
|
||||||
|
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.contractService.generateContract(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract')
|
||||||
|
@ApiOperation({ summary: 'Download contract file' })
|
||||||
|
async downloadContract(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const { stream, record } = await this.contractService.streamContract(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': record.mimeType ?? 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${record.name}"`,
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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')
|
||||||
|
@ApiOperation({ summary: 'Customer digital signature' })
|
||||||
|
async customerSign(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.transitionService.customerSign(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/marketing/approve')
|
||||||
|
@ApiOperation({ summary: 'Marketing verify and fully execute' })
|
||||||
|
async marketingApprove(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: MarketingApproveDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.marketingApprove(
|
||||||
|
id,
|
||||||
|
dto.actorId,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/payment/pnr')
|
||||||
|
@ApiOperation({ summary: 'Generate PNR code (ETB)' })
|
||||||
|
async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.paymentService.generatePnr(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/payment/proof')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Upload USD payment proof' })
|
||||||
|
async submitPaymentProof(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
const file = files?.[0];
|
||||||
|
const booking = await this.paymentService.submitPaymentProof(id, file);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/payment/request-letter')
|
||||||
|
@ApiOperation({ summary: 'Download payment request letter (USD stub)' })
|
||||||
|
@Header('Content-Type', 'text/plain')
|
||||||
|
async paymentRequestLetter(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const { buffer, filename } =
|
||||||
|
await this.paymentService.getPaymentRequestLetter(id);
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
return new StreamableFile(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/payment/verify')
|
||||||
|
@ApiOperation({ summary: 'Finance verify USD payment' })
|
||||||
|
async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.paymentService.verifyPayment(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/operations/start-transit')
|
||||||
|
@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')
|
||||||
|
@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')
|
||||||
|
@ApiOperation({ summary: 'Cancel booking' })
|
||||||
|
async cancel(
|
||||||
|
@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) {
|
||||||
return this.bookingsService.requestConsolidation(id);
|
return this.bookingsService.requestConsolidation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 9. Remove consolidation pairing ───────────────────────────────────
|
@Delete(':id/consolidation')
|
||||||
@Delete(":id/consolidation")
|
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||||
@ApiOperation({
|
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
summary: "Remove consolidation pairing",
|
|
||||||
description:
|
|
||||||
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
|
||||||
})
|
|
||||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.bookingsService.removeConsolidation(id);
|
return this.bookingsService.removeConsolidation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 10. Get consolidation details ─────────────────────────────────────
|
@Get(':id/consolidation')
|
||||||
@Get(":id/consolidation")
|
@ApiOperation({ summary: 'Get consolidation details' })
|
||||||
@ApiOperation({
|
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
summary: "Get consolidation details",
|
|
||||||
description:
|
|
||||||
"Returns partner booking details and split billing information.",
|
|
||||||
})
|
|
||||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.bookingsService.getConsolidationDetails(id);
|
return this.bookingsService.getConsolidationDetails(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,21 @@ import { CustomersModule } from '../customers/customers.module';
|
|||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { PaymentsWebhookController } from './payments-webhook.controller';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -24,18 +30,23 @@ import { Booking } from './entities/booking.entity';
|
|||||||
BookingCargoModifier,
|
BookingCargoModifier,
|
||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
|
BookingReviewNote,
|
||||||
]),
|
]),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController, PaymentsWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
|
BookingPricingService,
|
||||||
|
BookingTransitionService,
|
||||||
|
BookingContractService,
|
||||||
|
BookingPaymentService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService],
|
exports: [BookingsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||||
@@ -66,6 +67,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||||
|
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||||
.where('booking.id = :id', { id })
|
.where('booking.id = :id', { id })
|
||||||
.leftJoinAndMapMany(
|
.leftJoinAndMapMany(
|
||||||
'booking.files',
|
'booking.files',
|
||||||
@@ -216,15 +218,35 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get pending approval step for a role. */
|
/** Lowest-order pending approval step (sequential enforcement). */
|
||||||
|
async findNextPendingApprovalStep(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<BookingApprovalStep | null> {
|
||||||
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||||
|
where: { bookingId, status: 'PENDING' },
|
||||||
|
order: { stepOrder: 'ASC' },
|
||||||
|
relations: ['approvalRule'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findApprovalStepById(
|
||||||
|
bookingId: string,
|
||||||
|
stepId: string,
|
||||||
|
): Promise<BookingApprovalStep | null> {
|
||||||
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||||
|
where: { bookingId, id: stepId },
|
||||||
|
relations: ['approvalRule'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get pending approval step for a role (must match next in sequence). */
|
||||||
async findPendingApprovalStep(
|
async findPendingApprovalStep(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
requiredRole: string,
|
requiredRole: string,
|
||||||
): Promise<BookingApprovalStep | null> {
|
): Promise<BookingApprovalStep | null> {
|
||||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||||
where: { bookingId, requiredRole, status: 'PENDING' },
|
if (!next || next.requiredRole !== requiredRole) return null;
|
||||||
order: { stepOrder: 'ASC' },
|
return next;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark an approval step complete. */
|
/** Mark an approval step complete. */
|
||||||
@@ -277,4 +299,85 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
where: { bookingId, rateId },
|
where: { bookingId, rateId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createReviewNote(
|
||||||
|
bookingId: string,
|
||||||
|
note: string,
|
||||||
|
type: ReviewNoteType,
|
||||||
|
authorId?: string,
|
||||||
|
): Promise<BookingReviewNote> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||||
|
return repo.save(
|
||||||
|
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLatestReviewNote(
|
||||||
|
bookingId: string,
|
||||||
|
type?: ReviewNoteType,
|
||||||
|
): Promise<BookingReviewNote | null> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||||
|
return repo.findOne({
|
||||||
|
where: type ? { bookingId, type } : { bookingId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPricingArtifacts(bookingId: string): Promise<void> {
|
||||||
|
await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
|
||||||
|
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByPnrCode(pnrCode: string): Promise<Booking | null> {
|
||||||
|
return this.repository.findOne({ where: { pnrCode } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||||
|
async findQueue(options: {
|
||||||
|
status: string | string[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
excludeBulk?: boolean;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}): Promise<{ items: Booking[]; total: number }> {
|
||||||
|
const page = options.page ?? 1;
|
||||||
|
const pageSize = options.pageSize ?? 20;
|
||||||
|
const statuses = Array.isArray(options.status) ? options.status : [options.status];
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.customer', 'customer')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
|
.where('booking.status IN (:...statuses)', { statuses });
|
||||||
|
|
||||||
|
if (options.excludeBulk) {
|
||||||
|
qb.andWhere('cargo.requires_director_approval = false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortField =
|
||||||
|
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
|
||||||
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
const [items, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
order: Record<string, 'ASC' | 'DESC'>;
|
||||||
|
}): Promise<[Booking[], number]> {
|
||||||
|
return this.repository.findAndCount({
|
||||||
|
where,
|
||||||
|
skip: options.skip,
|
||||||
|
take: options.take,
|
||||||
|
order: options.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ConsolidationService } from './consolidation.service';
|
|||||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import { UpdateStatusDto } from './dto/update-status.dto';
|
import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
@@ -242,8 +242,10 @@ export class BookingsService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
if (existing.status !== 'DRAFT') {
|
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||||
throw new BadRequestException('Only DRAFT bookings can be updated');
|
throw new BadRequestException(
|
||||||
|
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -390,201 +392,32 @@ export class BookingsService {
|
|||||||
await this.bookingsRepository.softDelete(id);
|
await this.bookingsRepository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unified status transition handler. */
|
async findQueue(
|
||||||
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
|
queue: string,
|
||||||
const booking = await this.findById(id);
|
filter: FilterBookingDto,
|
||||||
const { action, actorId, reason, requiredRole } = dto;
|
options?: { excludeBulk?: boolean },
|
||||||
|
): Promise<{ items: Booking[]; total: number }> {
|
||||||
|
const statusMap: Record<string, string | string[]> = {
|
||||||
|
intake: 'SUBMITTED',
|
||||||
|
approval: 'PENDING_APPROVAL',
|
||||||
|
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
|
||||||
|
marketing: 'SIGNED_CUSTOMER',
|
||||||
|
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
};
|
||||||
|
|
||||||
switch (action) {
|
const status = statusMap[queue];
|
||||||
case 'SUBMIT':
|
if (!status) {
|
||||||
return this.handleSubmit(booking);
|
throw new BadRequestException(`Unknown queue: ${queue}`);
|
||||||
case 'SEND_QUOTATION':
|
|
||||||
return this.handleSendQuotation(booking);
|
|
||||||
case 'APPROVE_QUOTATION':
|
|
||||||
return this.handleApproveQuotation(booking);
|
|
||||||
case 'REJECT_QUOTATION':
|
|
||||||
return this.handleRejectQuotation(booking, reason);
|
|
||||||
case 'APPROVE_STEP':
|
|
||||||
return this.handleApproveStep(booking, actorId, requiredRole);
|
|
||||||
case 'APPROVE':
|
|
||||||
return this.handleFullyApproved(booking);
|
|
||||||
case 'CUSTOMER_SIGN':
|
|
||||||
return this.handleCustomerSign(booking);
|
|
||||||
case 'MARK_FULLY_EXECUTED':
|
|
||||||
return this.handleFullyExecuted(booking);
|
|
||||||
case 'MARK_PAID':
|
|
||||||
return this.handleMarkPaid(booking);
|
|
||||||
case 'START_TRANSIT':
|
|
||||||
return this.handleStartTransit(booking);
|
|
||||||
case 'COMPLETE':
|
|
||||||
return this.handleComplete(booking);
|
|
||||||
case 'REJECT':
|
|
||||||
return this.handleReject(booking, actorId, reason);
|
|
||||||
case 'CANCEL':
|
|
||||||
return this.handleCancel(booking, reason);
|
|
||||||
default:
|
|
||||||
throw new BadRequestException(`Unknown action: ${action}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
|
|
||||||
private async handleSubmit(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['DRAFT']);
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
|
|
||||||
await this.ruleEngineService.snapshotLiveRates(booking.id);
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'PENDING_APPROVAL',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSendQuotation(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['RFQ_SUBMITTED']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'QUOTATION_SENT',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleApproveQuotation(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'QUOTATION_APPROVED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRejectQuotation(booking: Booking, reason?: string): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
|
||||||
if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'QUOTATION_REJECTED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleApproveStep(
|
|
||||||
booking: Booking,
|
|
||||||
actorId?: string,
|
|
||||||
requiredRole?: string,
|
|
||||||
): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
|
||||||
if (!actorId || !requiredRole) {
|
|
||||||
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = await this.bookingsRepository.findPendingApprovalStep(
|
return this.bookingsRepository.findQueue({
|
||||||
booking.id,
|
status,
|
||||||
requiredRole,
|
page: filter.page,
|
||||||
);
|
pageSize: filter.pageSize,
|
||||||
if (!step) {
|
excludeBulk: options?.excludeBulk ?? queue === 'approval',
|
||||||
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
|
sortBy: filter.sortBy,
|
||||||
}
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
|
||||||
|
|
||||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
|
|
||||||
if (allDone) {
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'APPROVED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.findById(booking.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFullyApproved(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'APPROVED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCustomerSign(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['APPROVED']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'SIGNED_CUSTOMER',
|
|
||||||
customerSignedAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'FULLY_EXECUTED',
|
|
||||||
fullyExecutedAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMarkPaid(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'PAID',
|
|
||||||
paymentStatus: 'PAID',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStartTransit(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PAID']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'IN_TRANSIT',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleComplete(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['IN_TRANSIT']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'COMPLETED',
|
|
||||||
endDate: new Date(),
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleReject(
|
|
||||||
booking: Booking,
|
|
||||||
actorId?: string,
|
|
||||||
reason?: string,
|
|
||||||
): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
|
||||||
if (!actorId || !reason) {
|
|
||||||
throw new BadRequestException('actorId and reason are required for REJECT');
|
|
||||||
}
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'CANCELLED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCancel(booking: Booking, reason?: string): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, [
|
|
||||||
'DRAFT',
|
|
||||||
'RFQ_SUBMITTED',
|
|
||||||
'QUOTATION_SENT',
|
|
||||||
'QUOTATION_APPROVED',
|
|
||||||
'PENDING_APPROVAL',
|
|
||||||
]);
|
|
||||||
if (!reason) throw new BadRequestException('reason is required for CANCEL');
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'CANCELLED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertStatus(booking: Booking, allowed: string[]): void {
|
|
||||||
if (!allowed.includes(booking.status)) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestConsolidation(id: string): Promise<{
|
async requestConsolidation(id: string): Promise<{
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PriceLineItemDto {
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeneratePriceResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PriceLineItemDto] })
|
||||||
|
lineItems!: PriceLineItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
warnings!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RequestChangesDto {
|
||||||
|
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
note!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StaffAcceptDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarketingApproveDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StaffRejectDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
actorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApproveStepDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||||
|
@IsString()
|
||||||
|
requiredRole!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectStepDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelBookingDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankCallbackDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
pnrCode!: string;
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
const STATUS_ACTIONS = [
|
|
||||||
'SUBMIT',
|
|
||||||
'SEND_QUOTATION',
|
|
||||||
'APPROVE_QUOTATION',
|
|
||||||
'REJECT_QUOTATION',
|
|
||||||
'APPROVE_STEP',
|
|
||||||
'APPROVE',
|
|
||||||
'CUSTOMER_SIGN',
|
|
||||||
'MARK_FULLY_EXECUTED',
|
|
||||||
'MARK_PAID',
|
|
||||||
'START_TRANSIT',
|
|
||||||
'COMPLETE',
|
|
||||||
'REJECT',
|
|
||||||
'CANCEL',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export { STATUS_ACTIONS };
|
|
||||||
|
|
||||||
export class UpdateStatusDto {
|
|
||||||
@ApiProperty({ enum: STATUS_ACTIONS })
|
|
||||||
@IsIn([...STATUS_ACTIONS])
|
|
||||||
action!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
actorId?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
requiredRole?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
|
||||||
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class BookingReviewNote extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'author_id', type: 'uuid', nullable: true })
|
||||||
|
authorId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'note', type: 'text' })
|
||||||
|
note!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'varchar', length: 30 })
|
||||||
|
type!: ReviewNoteType;
|
||||||
|
}
|
||||||
@@ -11,25 +11,47 @@ import { BookingApprovalStep } from './booking-approval-step.entity';
|
|||||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './booking-container.entity';
|
import { BookingContainer } from './booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||||
|
import { BookingReviewNote } from './booking-review-note.entity';
|
||||||
|
|
||||||
export const BOOKING_STATUSES = [
|
export const BOOKING_STATUSES = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'RFQ_SUBMITTED',
|
'SUBMITTED',
|
||||||
'QUOTATION_SENT',
|
'CHANGES_REQUESTED',
|
||||||
'QUOTATION_APPROVED',
|
|
||||||
'QUOTATION_REJECTED',
|
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
'APPROVED',
|
'APPROVED',
|
||||||
|
'CONTRACT_READY',
|
||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
'PAID',
|
'PAID',
|
||||||
'IN_TRANSIT',
|
'IN_TRANSIT',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
|
'REJECTED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'PENDING_CONSOLIDATION',
|
'PENDING_CONSOLIDATION',
|
||||||
'CONSOLIDATED',
|
'CONSOLIDATED',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PAYMENT_STATUSES = [
|
||||||
|
'PENDING',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'VERIFICATION_IN_PROGRESS',
|
||||||
|
'PAID',
|
||||||
|
'FAILED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Statuses where the customer may edit booking fields. */
|
||||||
|
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||||
|
'DRAFT',
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'bookings' })
|
@Entity({ schema: 'freight', name: 'bookings' })
|
||||||
export class Booking extends BaseEntity {
|
export class Booking extends BaseEntity {
|
||||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||||
@@ -169,6 +191,18 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
||||||
fullyExecutedAt?: Date | null;
|
fullyExecutedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true })
|
||||||
|
marketingApprovedById?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true })
|
||||||
|
marketingApprovedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||||
|
contractSummary?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||||
|
lockedAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
||||||
priorityScore!: number;
|
priorityScore!: number;
|
||||||
|
|
||||||
@@ -194,6 +228,9 @@ export class Booking extends BaseEntity {
|
|||||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||||
rateSnapshots?: BookingRateSnapshot[];
|
rateSnapshots?: BookingRateSnapshot[];
|
||||||
|
|
||||||
|
@OneToMany(() => BookingReviewNote, (n) => n.booking)
|
||||||
|
reviewNotes?: BookingReviewNote[];
|
||||||
|
|
||||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||||
createForeignKeyConstraints: false,
|
createForeignKeyConstraints: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
|
import { BankCallbackDto } from './dto/request-changes.dto';
|
||||||
|
|
||||||
|
@ApiTags('payments')
|
||||||
|
@Controller('webhooks/payments')
|
||||||
|
export class PaymentsWebhookController {
|
||||||
|
constructor(private readonly paymentService: BookingPaymentService) {}
|
||||||
|
|
||||||
|
@Post('bank')
|
||||||
|
@ApiOperation({ summary: 'Bank payment callback (stub)' })
|
||||||
|
bankCallback(@Body() dto: BankCallbackDto) {
|
||||||
|
return this.paymentService.handleBankCallback(dto.pnrCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user