mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
complete rule engine and booking flow
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user