complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:27:59 +03:00
parent 800f036005
commit 430dc44937
74 changed files with 4304 additions and 1248 deletions

View File

@@ -92,8 +92,8 @@ export class BookingsController {
@ApiOperation({
summary: "List freight bookings (paginated)",
description:
"Filter by status, customerId, contractType, serviceType, tradeDirection, " +
"paymentCurrency, freightType, containerType, allowConsolidation, consolidationPaired. " +
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
"paymentCurrency, allowConsolidation, consolidationPaired. " +
"Sort by createdAt or priorityScore.",
})
findAll(@Query() filter: FilterBookingDto) {

View File

@@ -1,17 +1,33 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomersModule } from "../customers/customers.module";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
import { BookingsController } from "./bookings.controller";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { Booking } from "./entities/booking.entity";
import { CustomersModule } from '../customers/customers.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { BookingsController } from './bookings.controller';
import { BookingsRepository } from './bookings.repository';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { Booking } from './entities/booking.entity';
@Module({
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule, RuleEngineModule],
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
]),
FilesModule,
MinioModule,
CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],

View File

@@ -1,16 +1,23 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, IsNull, Not, Repository } from "typeorm";
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Not, Repository } from 'typeorm';
import { Booking } from "./entities/booking.entity";
import { FileRecord } from "../files/entities/file.entity";
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@InjectRepository(Booking)
repository: Repository<Booking>,
private readonly dataSource: DataSource,
) {
super(repository);
}
@@ -24,59 +31,114 @@ export class BookingsRepository extends BaseRepository<Booking> {
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
.createQueryBuilder("booking")
.where("booking.created_at >= :startDate", { startDate })
.andWhere("booking.created_at < :endDate", { endDate })
.createQueryBuilder('booking')
.where('booking.created_at >= :startDate', { startDate })
.andWhere('booking.created_at < :endDate', { endDate })
.getCount();
}
/** Find a booking by reference with associated files (polymorphic join). */
/** Find a booking by reference with files and relations. */
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
const booking = await this.repository
.createQueryBuilder("booking")
.where("booking.reference = :reference", { reference })
.leftJoinAndMapMany(
"booking.files",
FileRecord,
"file",
"file.resource_id = booking.id AND file.resource = 'bookings'"
)
.getOne();
return booking ?? null;
return this.findByIdWithFiles(
(
await this.repository.findOne({ where: { reference }, select: ['id'] })
)?.id ?? '',
);
}
/** Find a booking by ID with associated files (polymorphic join). */
/** Find a booking by ID with files, containers, and config relations. */
async findByIdWithFiles(id: string): Promise<Booking | null> {
if (!id) return null;
const booking = await this.repository
.createQueryBuilder("booking")
.where("booking.id = :id", { id })
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('booking.serviceType', 'st')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.originYard', 'oy')
.leftJoinAndSelect('booking.destinationYard', 'dy')
.leftJoinAndSelect('booking.shippingLine', 'sl')
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
"booking.files",
'booking.files',
FileRecord,
"file",
"file.resource_id = booking.id AND file.resource = 'bookings'"
'file',
"file.resource_id = booking.id AND file.resource = 'bookings'",
)
.getOne();
return booking ?? null;
}
/** Find a compatible consolidation partner for the given booking. */
/** Persist booking container rows with weight rule results. */
async createContainers(
bookingId: string,
containers: Array<{
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
const containerRepo = this.dataSource.getRepository(BookingContainer);
const typeRepo = this.dataSource.getRepository(ContainerType);
const saved: BookingContainer[] = [];
for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
const row = containerRepo.create({
bookingId,
containerTypeId: item.containerTypeId,
quantity: item.quantity,
vgmPerUnitTons: item.vgmPerUnitTons,
totalVgmTons: totalVgm,
wagonsRequired,
weightLimitRuleId: item.weightResult.weightLimitRuleId,
isOverweight: item.weightResult.isOverweight,
overweightExcessTons: item.weightResult.overweightExcessTons,
});
saved.push(await containerRepo.save(row));
}
return saved;
}
/** SQL aggregate wagon count for a booking. */
async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource
.createQueryBuilder()
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
.from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId })
.getRawOne<{ total: string }>();
return Number(result?.total ?? 0);
}
/** Find a compatible consolidation partner. */
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
return this.repository.findOne({
where: {
allowConsolidation: true,
// Check if containers JSONB contains at least one 20FT entry with odd qty
containers: Not(IsNull()),
originStation: booking.originStation,
destinationStation: booking.destinationStation,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
tradeDirection: booking.tradeDirection,
consolidationPartnerId: IsNull(),
status: In(["DRAFT", "PENDING_CONSOLIDATION"]),
status: In(['DRAFT', 'PENDING_CONSOLIDATION']),
id: Not(booking.id),
},
order: { createdAt: "ASC" },
order: { createdAt: 'ASC' },
});
}
@@ -84,23 +146,90 @@ export class BookingsRepository extends BaseRepository<Booking> {
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: "CONSOLIDATED",
status: 'CONSOLIDATED',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: "CONSOLIDATED",
status: 'CONSOLIDATED',
} as never);
}
/** Un-pair a consolidation. Returns both booking IDs. */
/** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
status: 'PENDING_CONSOLIDATION',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
status: 'PENDING_CONSOLIDATION',
} as never);
}
/** Delete all containers for a booking (used on draft update). */
async deleteContainers(bookingId: string): Promise<void> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Get pending approval step for a role. */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, requiredRole, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
remarks?: string,
): Promise<void> {
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
status,
actionedByStaffId: actorId,
actionedAt: new Date(),
remarks,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
where: { bookingId, status: 'PENDING' },
});
return pending === 0;
}
/** Persist cargo modifiers linked to rate snapshots. */
async createCargoModifiers(
rows: Array<{
bookingId: string;
surchargeTypeId: string;
triggerValue: number | null;
calculatedAmount: number;
rateSnapshotId: string;
}>,
): Promise<BookingCargoModifier[]> {
const repo = this.dataSource.getRepository(BookingCargoModifier);
const saved: BookingCargoModifier[] = [];
for (const row of rows) {
saved.push(await repo.save(repo.create(row)));
}
return saved;
}
/** Find rate snapshot by rate id for a booking. */
async findRateSnapshotByRateId(
bookingId: string,
rateId: string,
): Promise<BookingRateSnapshot | null> {
return this.dataSource.getRepository(BookingRateSnapshot).findOne({
where: { bookingId, rateId },
});
}
}

View File

@@ -3,20 +3,24 @@ import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { IsNull, Not } from "typeorm";
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { CustomersService } from "../customers/customers.service";
import { FilesService } from "../files/files.service";
import { MinioService } from "../minio/minio.service";
import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { BookingsRepository } from "./bookings.repository";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import { UpdateStatusDto } from "./dto/update-status.dto";
import { Booking } from "./entities/booking.entity";
import { FileRecord } from "../files/entities/file.entity";
import { CustomersService } from '../customers/customers.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { UpdateStatusDto } from './dto/update-status.dto';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
@Injectable()
export class BookingsService {
@@ -26,53 +30,68 @@ export class BookingsService {
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
) {}
// ── helpers ──────────────────────────────────────────────────────────
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `BK-${year}`;
// Get the count of bookings created this year
const count = await this.bookingsRepository.countByYear(year);
const sequenceNumber = String(count + 1).padStart(6, '0');
return `${prefix}-${sequenceNumber}`;
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
/** Resolve auto-consolidation flag. */
private resolveConsolidation(
containers: Array<{ type: string; qty: number }> | undefined | null,
explicit?: boolean,
): boolean {
if (explicit === false) return false;
if (!containers || containers.length === 0) return explicit ?? false;
// Auto-enable if any 20FT container has odd quantity
const needsConsolidation = containers.some(
(c) => c.type === "20FT" && c.qty % 2 !== 0
/** Build evaluation input from DTO containers. */
private async buildEvalInput(
dto: Pick<
CreateBookingDto,
| 'cargoTypeId'
| 'serviceTypeId'
| 'paymentCurrency'
| 'tradeDirection'
| 'isHazardous'
| 'allowConsolidation'
| 'shippingLineId'
| 'containers'
>,
): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
dto.containers.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
};
}),
);
if (needsConsolidation) return true;
return {
cargoTypeId: dto.cargoTypeId,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
allowConsolidation: dto.allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
};
}
/** Resolve auto-consolidation for odd-quantity 20ft containers. */
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
for (const c of containers) {
const ct = await this.containerTypesService.findById(c.containerTypeId);
if (ct.sizeFt === 20 && c.quantity % 2 !== 0) return true;
}
return explicit ?? false;
}
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
private calculateWagonCount(
containers: Array<{ type: string; qty: number }>,
): number {
return containers.reduce((total, container) => {
if (container.type === "40FT") {
return total + container.qty;
}
// 20FT: 1 wagon per 2 containers (rounded up)
return total + Math.ceil(container.qty / 2);
}, 0);
}
// ── CRUD ─────────────────────────────────────────────────────────────
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -81,65 +100,83 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
// Resolve customerId: use provided value (admin) or look up by IAM userId
let customerId = dto.customerId;
if (!customerId) {
if (!userId) {
throw new BadRequestException('customerId is required or must be resolvable from auth token');
throw new BadRequestException(
'customerId is required or must be resolvable from auth token',
);
}
const customer = await this.customersService.findByUserId(userId);
customerId = customer.id;
}
// Generate reference if not provided
const reference = dto.reference || await this.generateReference();
const allowConsolidation = this.resolveConsolidation(
const reference = dto.reference || (await this.generateReference());
const allowConsolidation = await this.resolveConsolidation(
dto.containers,
dto.allowConsolidation,
);
// ── Rule engine evaluation ──────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType,
serviceType: dto.serviceType,
paymentCurrency: dto.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? false,
containers: dto.containers,
});
const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const wagonCount = this.calculateWagonCount(dto.containers);
warnings.push(`Estimated wagons required: ${wagonCount}`);
const booking = await this.bookingsRepository.create({
...dto,
reference,
customerId,
totalAmount: 0,
paymentStatus: "PENDING",
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
cargoTypeId: dto.cargoTypeId,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
scheduledDate: new Date(dto.scheduledDate),
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: "DRAFT",
status: 'DRAFT',
allowConsolidation,
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
await this.bookingsRepository.createContainers(
booking.id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
warnings.push(`Estimated wagons required: ${wagonCount}`);
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, "bookings", files);
} catch (err) {
console.error('[BookingsService] File upload failed, booking still created:', err);
await this.filesService.uploadMany(booking.id, 'bookings', files);
} catch {
warnings.push('File upload failed — booking was created without attached files.');
}
}
return { booking, warnings };
const full = await this.findById(booking.id);
return { booking: full, warnings };
}
/** Update a draft booking. */
@@ -149,46 +186,67 @@ export class BookingsService {
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
if (existing.status !== "DRAFT") {
throw new BadRequestException("Only DRAFT bookings can be updated");
if (existing.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be updated');
}
const warnings: string[] = [];
const updates: Record<string, unknown> = { ...dto };
const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ?? [];
const allowConsolidation = await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
);
const evalInput = await this.buildEvalInput({
cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const updates: Record<string, unknown> = {
...dto,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
// Re-evaluate consolidation if containers changed
const containers = dto.containers ?? existing.containers ?? [];
updates.allowConsolidation = this.resolveConsolidation(
containers,
dto.allowConsolidation,
);
await this.bookingsRepository.update(id, updates);
// ── Rule engine re-evaluation ────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType ?? existing.freightType,
serviceType: dto.serviceType ?? existing.serviceType,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false,
containers,
});
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
updates.priorityScore = ruleResult.priorityScore;
if (files.length > 0) {
await this.filesService.uploadMany(id, "bookings", files);
if (dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
}
const booking = await this.bookingsRepository.update(id, updates);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
const booking = await this.findById(id);
return { booking, warnings };
}
@@ -203,19 +261,21 @@ export class BookingsService {
if (filter.status) where.status = filter.status;
if (filter.customerId) where.customerId = filter.customerId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceType) where.serviceType = filter.serviceType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.freightType) where.freightType = filter.freightType;
if (filter.allowConsolidation !== undefined)
if (filter.allowConsolidation !== undefined) {
where.allowConsolidation = filter.allowConsolidation;
if (filter.consolidationPaired === "true")
}
if (filter.consolidationPaired === 'true') {
where.consolidationPartnerId = Not(IsNull());
else if (filter.consolidationPaired === "false")
} else if (filter.consolidationPaired === 'false') {
where.consolidationPartnerId = IsNull();
}
const sortField = filter.sortBy ?? "createdAt";
const sortDir = filter.sortOrder ?? "DESC";
const sortField = filter.sortBy ?? 'createdAt';
const sortDir = filter.sortOrder ?? 'DESC';
const [items, total] = await this.bookingsRepository.findAndCount({
where,
@@ -226,245 +286,244 @@ export class BookingsService {
return { items, total };
}
/** Get a single booking by ID with files, throwing if not found. */
/** Get a single booking by ID with files. */
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {
throw new NotFoundException(`Booking ${id} not found`);
}
// Add signed URLs for files (5-minute expiration)
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
})
}),
);
}
return booking;
}
/** Extract object name from Minio URL. */
private extractObjectName(url: string): string {
const parts = url.split("/");
return parts.slice(4).join("/");
const parts = url.split('/');
return parts.slice(4).join('/');
}
/** Find booking by reference with files. */
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
throw new NotFoundException(`Booking with reference "${reference}" not found`);
}
// Add signed URLs for files (5-minute expiration)
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
})
);
}
return booking;
return this.findById(booking.id);
}
/** Soft-delete a booking (DRAFT only). */
async remove(id: string): Promise<void> {
const booking = await this.findById(id);
if (booking.status !== "DRAFT") {
throw new BadRequestException("Only DRAFT bookings can be deleted");
if (booking.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be deleted');
}
await this.bookingsRepository.softDelete(id);
}
// ── status workflow ──────────────────────────────────────────────────
/** Unified status transition handler. */
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
const booking = await this.findById(id);
const { action, actorId, reason } = dto;
const { action, actorId, reason, requiredRole } = dto;
switch (action) {
case "SUBMIT":
case 'SUBMIT':
return this.handleSubmit(booking);
case "APPROVE_STAFF":
return this.handleApproveStaff(booking, actorId);
case "APPROVE_DIRECTOR":
return this.handleApproveDirector(booking, actorId);
case "APPROVE_CEO":
return this.handleApproveCeo(booking, actorId);
case "REJECT":
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, actorId, reason);
case "ACTIVATE":
return this.handleActivate(booking);
case "EXPIRE":
return this.handleExpire(booking);
case 'CANCEL':
return this.handleCancel(booking, reason);
default:
throw new BadRequestException(`Unknown action: ${action}`);
}
}
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
private async handleSubmit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["DRAFT"]);
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval
? "PENDING_DIRECTOR"
: "PENDING_LINE_STAFF";
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: nextStatus,
status: 'PENDING_APPROVAL',
} as never);
return updated!;
}
/** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */
private async handleApproveStaff(
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_LINE_STAFF"]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_STAFF");
// Line staff cannot approve bookings that require director approval
const ruleResult = await this.ruleEngineService.evaluate(booking);
if (ruleResult.requiresDirectorApproval) {
throw new BadRequestException(
"Line staff cannot approve bookings that require director approval",
);
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(
booking.id,
requiredRole,
);
if (!step) {
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
}
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_PENDING_SIGNATURE",
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
status: 'APPROVED',
} as never);
return updated!;
}
/** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */
private async handleApproveDirector(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"APPROVED_PENDING_SIGNATURE",
"PENDING_DIRECTOR",
]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_DIRECTOR");
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED";
private async handleCustomerSign(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['APPROVED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
signedByDirectorId: actorId,
signedByDirectorAt: new Date(),
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return updated!;
}
/** APPROVE_CEO: PENDING_CEO → SIGNED. */
private async handleApproveCeo(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, ["PENDING_CEO"]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_CEO");
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "SIGNED",
signedByCeoId: actorId,
signedByCeoAt: new Date(),
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
} as never);
return updated!;
}
/** REJECT: PENDING_* → CANCELLED. */
private async handleReject(
booking: Booking,
actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
]);
if (!actorId || !reason)
throw new BadRequestException("actorId and reason are required for REJECT");
private async handleMarkPaid(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
}
/** CANCEL: DRAFT|PENDING_* → CANCELLED. */
private async handleCancel(
booking: Booking,
_actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
]);
if (!reason)
throw new BadRequestException("reason is required for CANCEL");
private async handleStartTransit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
status: 'IN_TRANSIT',
} as never);
return updated!;
}
/** ACTIVATE: SIGNED → ACTIVE. */
private async handleActivate(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["SIGNED"]);
private async handleComplete(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['IN_TRANSIT']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "ACTIVE",
startDate: booking.startDate ?? new Date(),
} as never);
return updated!;
}
/** EXPIRE: ACTIVE → EXPIRED. */
private async handleExpire(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["ACTIVE"]);
const updated = await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
status: 'COMPLETED',
endDate: new Date(),
} as never);
return updated!;
}
/** Guard: ensure current status is one of the allowed values. */
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 a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`,
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
);
}
}
// ── consolidation ────────────────────────────────────────────────────
/** Request consolidation — auto-pair if a partner exists, else queue. */
async requestConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking | null;
@@ -473,61 +532,63 @@ export class BookingsService {
const booking = await this.findById(id);
if (!booking.allowConsolidation) {
throw new BadRequestException("Booking is not eligible for consolidation");
throw new BadRequestException('Booking is not eligible for consolidation');
}
// Check if any 20FT container has odd quantity
const hasOdd20FT = booking.containers?.some(
(c) => c.type === "20FT" && c.qty % 2 !== 0
) ?? false;
if (!hasOdd20FT) {
const hasOdd20Ft = await this.hasOdd20FtContainer(booking);
if (!hasOdd20Ft) {
throw new BadRequestException(
"Only bookings with odd-quantity 20FT containers need consolidation",
'Only bookings with odd-quantity 20ft containers need consolidation',
);
}
if (booking.consolidationPartnerId) {
throw new ConflictException("Booking is already paired for consolidation");
throw new ConflictException('Booking is already paired for consolidation');
}
const partner =
await this.bookingsRepository.findConsolidationPartner(booking);
const partner = await this.bookingsRepository.findConsolidationPartner(booking);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const updated = await this.findById(id);
const updatedPartner = await this.findById(partner.id);
return { booking: updated, partner: updatedPartner, paired: true };
return {
booking: await this.findById(id),
partner: await this.findById(partner.id),
paired: true,
};
}
// No partner found — enter queue
await this.bookingsRepository.update(booking.id, {
status: "PENDING_CONSOLIDATION",
status: 'PENDING_CONSOLIDATION',
} as never);
const updated = await this.findById(id);
return { booking: updated, partner: null, paired: false };
return { booking: await this.findById(id), partner: null, paired: false };
}
/** Remove consolidation pairing. */
async removeConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking;
}> {
private async hasOdd20FtContainer(booking: Booking): Promise<boolean> {
const containers = booking.bookingContainers ?? [];
for (const bc of containers) {
const ct =
bc.containerType ??
(await this.containerTypesService.findById(bc.containerTypeId));
if (ct.sizeFt === 20 && bc.quantity % 2 !== 0) return true;
}
return false;
}
async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.findById(id);
if (!booking.consolidationPartnerId) {
throw new BadRequestException("Booking has no consolidation partner");
throw new BadRequestException('Booking has no consolidation partner');
}
const partnerId = booking.consolidationPartnerId;
await this.bookingsRepository.unpairConsolidation(id, partnerId);
const updated = await this.findById(id);
const updatedPartner = await this.findById(partnerId);
return { booking: updated, partner: updatedPartner };
return {
booking: await this.findById(id),
partner: await this.findById(partnerId),
};
}
/** Get consolidation details for a booking. */
async getConsolidationDetails(id: string): Promise<{
booking: Booking;
partner: Booking | null;
@@ -540,11 +601,13 @@ export class BookingsService {
}
const partner = await this.findById(booking.consolidationPartnerId);
const splitBilling = {
bookingShare: booking.totalAmount,
partnerShare: partner.totalAmount,
return {
booking,
partner,
splitBilling: {
bookingShare: Number(booking.totalAmount),
partnerShare: Number(partner.totalAmount),
},
};
return { booking, partner, splitBilling };
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
@@ -12,117 +12,81 @@ import {
IsUUID,
Min,
ValidateNested,
} from "class-validator";
} from 'class-validator';
import { BOOKING_STATUSES } from '../entities/booking.entity';
const BOOKING_STATUSES = [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
"SIGNED",
"ACTIVE",
"EXPIRED",
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
] as const;
const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const;
const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const;
const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const;
const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const;
const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const;
const PAYMENT_CURRENCIES = ["ETB", "USD"] as const;
const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const;
const CONTAINER_TYPES = ["20FT", "40FT"] as const;
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
CONTRACT_TYPES,
SERVICE_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
PAYMENT_STATUSES,
CONTAINER_TYPES,
};
export class ContainerItem {
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
@IsIn([...CONTAINER_TYPES])
type!: string;
export class CreateBookingContainerDto {
@ApiProperty({ format: 'uuid', description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
@ApiProperty({ description: 'Quantity of containers', minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
qty!: number;
quantity!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgm!: number;
vgmPerUnitTons!: number;
}
export class CreateBookingDto {
// ── core ─────────────────────────────────────────────────────────────
@ApiPropertyOptional({ description: "Unique booking reference (auto-generated if not provided)" })
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === "string" ? value.trim() : value))
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
@ApiPropertyOptional({ format: "uuid", description: "Admin only: target customer. Omit to resolve from auth token." })
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ format: "uuid" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainId?: string;
@ApiProperty({ example: "2026-06-15T00:00:00.000Z" })
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;
// ── contract ─────────────────────────────────────────────────────────
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === "" || value == null ? undefined : value))
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
previousContractId?: string;
@ApiProperty({ enum: SERVICE_TYPES })
@IsIn([...SERVICE_TYPES])
serviceType!: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
firstMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when firstMileEnabled is true" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
lastMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when lastMileEnabled is true" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@@ -131,55 +95,59 @@ export class CreateBookingDto {
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@ApiProperty({ description: "Origin station name or code" })
@IsString()
originStation!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ description: "Destination station name or code" })
@IsString()
destinationStation!: string;
@ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiProperty({ enum: FREIGHT_TYPES })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." })
@IsOptional()
@IsString()
freightSubtype?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isRefrigerated?: boolean;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
@IsUUID()
cargoTypeId!: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' })
@IsOptional()
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional({ example: "2026-06-15" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
pnrCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ example: "2027-06-15" })
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
endDate?: string;
@@ -189,19 +157,15 @@ export class CreateBookingDto {
@IsString()
financialTerms?: string;
// ── containers ────────────────────────────────────────────────────────
@ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
@ApiProperty({ type: [CreateBookingContainerDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerItem)
containers!: ContainerItem[];
@Type(() => CreateBookingContainerDto)
containers!: CreateBookingContainerDto[];
@ApiPropertyOptional({
default: false,
description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
})
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
}

View File

@@ -1,15 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator";
import {
BOOKING_STATUSES,
CONTRACT_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
SERVICE_TYPES,
TRADE_DIRECTIONS,
} from "./create-booking.dto";
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -17,20 +9,24 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({ format: "uuid" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ enum: CONTRACT_TYPES })
@ApiPropertyOptional()
@IsOptional()
@IsIn([...CONTRACT_TYPES])
contractType?: string;
@ApiPropertyOptional({ enum: SERVICE_TYPES })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsIn([...SERVICE_TYPES])
serviceType?: string;
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@@ -42,43 +38,31 @@ export class FilterBookingDto {
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@ApiPropertyOptional()
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
@ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" })
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
@IsOptional()
@IsString()
consolidationPaired?: string;
@ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" })
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsIn(["createdAt", "priorityScore"])
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -1,41 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
const STATUS_ACTIONS = [
"SUBMIT",
"APPROVE_STAFF",
"APPROVE_DIRECTOR",
"APPROVE_CEO",
"REJECT",
"CANCEL",
"ACTIVATE",
"EXPIRE",
'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,
description:
"SUBMIT — send to approval queue | " +
"APPROVE_STAFF — line-staff approval | " +
"APPROVE_DIRECTOR — director approval / signature | " +
"APPROVE_CEO — CEO final signature | " +
"REJECT — reject at any pending stage | " +
"CANCEL — customer / admin cancellation | " +
"ACTIVATE — activate a signed booking | " +
"EXPIRE — mark an active booking as expired",
})
@ApiProperty({ enum: STATUS_ACTIONS })
@IsIn([...STATUS_ACTIONS])
action!: string;
@ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" })
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
@IsOptional()
@IsUUID()
actorId?: string;
@ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" })
@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;

View File

@@ -0,0 +1,45 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
import { Booking } from './booking.entity';
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
@Entity({ schema: 'freight', name: 'booking_approval_step' })
@Index(['bookingId'])
@Index(['status'])
@Index(['bookingId', 'stepOrder'])
export class BookingApprovalStep extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'approval_rule_id', type: 'uuid' })
approvalRuleId!: string;
@ManyToOne(() => ApprovalRule)
@JoinColumn({ name: 'approval_rule_id' })
approvalRule?: ApprovalRule;
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus;
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
actionedByStaffId?: string | null;
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
actionedAt?: Date | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity';
import { Booking } from './booking.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
@Index(['bookingId'])
@Index(['surchargeTypeId'])
export class BookingCargoModifier extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.cargoModifiers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'surcharge_type_id', type: 'uuid' })
surchargeTypeId!: string;
@ManyToOne(() => SurchargeType)
@JoinColumn({ name: 'surcharge_type_id' })
surchargeType?: SurchargeType;
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
triggerValue?: number | null;
@Column({ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 })
calculatedAmount!: number;
@Column({ name: 'rate_snapshot_id', type: 'uuid' })
rateSnapshotId!: string;
@ManyToOne(() => BookingRateSnapshot)
@JoinColumn({ name: 'rate_snapshot_id' })
rateSnapshot?: BookingRateSnapshot;
}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { Booking } from './booking.entity';
@Entity({ schema: 'freight', name: 'booking_container' })
@Index(['bookingId'])
@Index(['isOverweight'])
export class BookingContainer extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.bookingContainers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@ManyToOne(() => ContainerType)
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType;
@Column({ name: 'quantity', type: 'smallint' })
quantity!: number;
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
vgmPerUnitTons!: number;
@Column({ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 })
totalVgmTons!: number;
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 })
wagonsRequired!: number;
@Column({ name: 'weight_limit_rule_id', type: 'uuid', nullable: true })
weightLimitRuleId?: string | null;
@ManyToOne(() => WeightLimitRule, { nullable: true })
@JoinColumn({ name: 'weight_limit_rule_id' })
weightLimitRule?: WeightLimitRule | null;
@Column({ name: 'is_overweight', type: 'boolean', default: false })
isOverweight!: boolean;
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
overweightExcessTons?: number | null;
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
@Entity({ schema: 'freight', name: 'booking_rate_snapshot' })
@Index(['bookingId'])
@Index(['rateId'])
@Index(['rateType'])
export class BookingRateSnapshot extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.rateSnapshots, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rate_id', type: 'uuid' })
rateId!: string;
@ManyToOne(() => Rate)
@JoinColumn({ name: 'rate_id' })
rate?: Rate;
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: string;
@Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 })
rateValue!: number;
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
rateUnit!: string;
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;
@Column({ name: 'snapshotted_at', type: 'timestamptz' })
snapshottedAt!: Date;
}

View File

@@ -1,148 +1,183 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, OneToMany } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
@Entity({ schema:"freight",name: "bookings" })
export const BOOKING_STATUSES = [
'DRAFT',
'RFQ_SUBMITTED',
'QUOTATION_SENT',
'QUOTATION_APPROVED',
'QUOTATION_REJECTED',
'PENDING_APPROVAL',
'APPROVED',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'PAID',
'IN_TRANSIT',
'COMPLETED',
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
] as const;
@Entity({ schema: 'freight', name: 'bookings' })
export class Booking extends BaseEntity {
// ── core ───────────────────────────────────────────────────────────────
@Column({ name: "reference", type: "varchar", length: 64, unique: true })
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: "customer_id", type: "uuid" })
@Column({ name: 'customer_id', type: 'uuid' })
customerId!: string;
@Column({ name: "train_id", type: "uuid", nullable: true })
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
@Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" })
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
@Column({ name: "scheduled_date", type: "timestamptz" })
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({
name: "total_amount",
type: "numeric",
precision: 14,
scale: 2,
default: 0,
})
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({
name: "payment_status",
type: "varchar",
length: 20,
default: "PENDING",
})
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;
// ── contract ───────────────────────────────────────────────────────────
@Column({ name: "contract_type", type: "varchar", length: 20 })
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType!: string;
@Column({ name: "previous_contract_id", type: "uuid", nullable: true })
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
previousContractId?: string | null;
@Column({ name: "service_type", type: "varchar", length: 30 })
serviceType!: string;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@Column({ name: "first_mile_enabled", type: "boolean", default: false })
firstMileEnabled!: boolean;
@ManyToOne(() => ServiceType)
@JoinColumn({ name: 'service_type_id' })
serviceType?: ServiceType;
@Column({ name: "first_mile_pickup_address", type: "text", nullable: true })
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: "last_mile_enabled", type: "boolean", default: false })
lastMileEnabled!: boolean;
@Column({ name: "last_mile_delivery_address", type: "text", nullable: true })
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: "equipment_return", type: "varchar", length: 20 })
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@Column({ name: "origin_station", type: "varchar", length: 255 })
originStation!: string;
@Column({ name: "destination_station", type: "varchar", length: 255 })
destinationStation!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@Column({
name: "cargo_total_weight_vgm",
type: "numeric",
precision: 12,
scale: 3,
})
cargoTotalWeightVgm!: number;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: "freight_type", type: "varchar", length: 20 })
freightType!: string;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true })
freightSubtype?: string | null;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: "is_hazardous", type: "boolean", default: false })
isHazardous!: boolean;
@Column({ name: "is_refrigerated", type: "boolean", default: false })
isRefrigerated!: boolean;
@Column({ name: "trade_direction", type: "varchar", length: 10 })
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: "payment_currency", type: "varchar", length: 5 })
@Column({ name: 'cargo_type_id', type: 'uuid' })
cargoTypeId!: string;
@ManyToOne(() => CargoType)
@JoinColumn({ name: 'cargo_type_id' })
cargoType?: CargoType;
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
cargoFreeText?: string | null;
@Column({ name: 'shipping_line_id', type: 'uuid', nullable: true })
shippingLineId?: string | null;
@ManyToOne(() => ShippingLine, { nullable: true })
@JoinColumn({ name: 'shipping_line_id' })
shippingLine?: ShippingLine | null;
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string;
@Column({ name: "start_date", type: "date", nullable: true })
@Column({ name: 'pnr_code', type: 'varchar', length: 50, nullable: true })
pnrCode?: string | null;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: Date | null;
@Column({ name: "end_date", type: "date", nullable: true })
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate?: Date | null;
@Column({ name: "financial_terms", type: "text", nullable: true })
@Column({ name: 'financial_terms', type: 'text', nullable: true })
financialTerms?: string | null;
@Column({ name: "version_number", type: "int", default: 1 })
@Column({ name: 'version_number', type: 'int', default: 1 })
versionNumber!: number;
// ── containers ─────────────────────────────────────────────────────────
@Column({ name: "containers", type: "jsonb", nullable: true })
containers!: Array<{ type: string; qty: number; vgm: number }> | null;
// ── approval ───────────────────────────────────────────────────────────
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })
@Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true })
approvedByStaffId?: string | null;
@Column({ name: "approved_by_staff_at", type: "timestamptz", nullable: true })
@Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true })
approvedByStaffAt?: Date | null;
@Column({ name: "signed_by_director_id", type: "uuid", nullable: true })
@Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true })
signedByDirectorId?: string | null;
@Column({ name: "signed_by_director_at", type: "timestamptz", nullable: true })
@Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true })
signedByDirectorAt?: Date | null;
@Column({ name: "signed_by_ceo_id", type: "uuid", nullable: true })
@Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true })
signedByCeoId?: string | null;
@Column({ name: "signed_by_ceo_at", type: "timestamptz", nullable: true })
@Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true })
signedByCeoAt?: Date | null;
@Column({ name: "priority_score", type: "int", default: 0 })
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
customerSignedAt?: Date | null;
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
fullyExecutedAt?: Date | null;
@Column({ name: 'priority_score', type: 'int', default: 0 })
priorityScore!: number;
// ── consolidation ──────────────────────────────────────────────────────
@Column({ name: "allow_consolidation", type: "boolean", default: false })
@Column({ name: 'allow_consolidation', type: 'boolean', default: false })
allowConsolidation!: boolean;
@Column({ name: "consolidation_partner_id", type: "uuid", nullable: true })
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
consolidationPartnerId?: string | null;
// ── files ────────────────────────────────────────────────────────────
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
approvalSteps?: BookingApprovalStep[];
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})
files?: FileRecord[];
}