mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
559 lines
19 KiB
TypeScript
559 lines
19 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { IsNull, Not } from "typeorm";
|
|
|
|
import { FilesService } from "../files/files.service";
|
|
import { MinioService } from "../minio/minio.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";
|
|
|
|
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
|
|
const WEIGHT_LIMITS = {
|
|
IMPORT_20FT: 20,
|
|
EXPORT_20FT: 25,
|
|
ANY_40FT: 32.5,
|
|
} as const;
|
|
|
|
/** Bookings above this total VGM are considered high-volume. */
|
|
const HIGH_VOLUME_THRESHOLD_TONS = 500;
|
|
|
|
@Injectable()
|
|
export class BookingsService {
|
|
constructor(
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly filesService: FilesService,
|
|
private readonly minioService: MinioService,
|
|
) {}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
|
|
/** 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
|
|
);
|
|
if (needsConsolidation) return true;
|
|
return explicit ?? false;
|
|
}
|
|
|
|
/** Calculate priority score based on currency and service type. */
|
|
private calculatePriorityScore(currency: string, serviceType: string): number {
|
|
let score = 0;
|
|
if (currency === "USD") score += 100;
|
|
if (serviceType === "RAIL_AND_FORWARDING") score += 50;
|
|
else if (serviceType === "RAIL_ONLY") score += 25;
|
|
return score;
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
/** Check per-container weight limits and return warnings if exceeded. */
|
|
private checkOverweight(
|
|
containers: Array<{ type: string; vgm: number }>,
|
|
tradeDirection: string,
|
|
): string[] {
|
|
const warnings: string[] = [];
|
|
for (const container of containers) {
|
|
if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) {
|
|
warnings.push(
|
|
`40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`
|
|
);
|
|
}
|
|
if (container.type === "20FT") {
|
|
const limit =
|
|
tradeDirection === "IMPORT"
|
|
? WEIGHT_LIMITS.IMPORT_20FT
|
|
: WEIGHT_LIMITS.EXPORT_20FT;
|
|
if (container.vgm > limit) {
|
|
warnings.push(
|
|
`20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
|
|
// ── CRUD ─────────────────────────────────────────────────────────────
|
|
|
|
/** Create a new freight booking. */
|
|
async create(
|
|
dto: CreateBookingDto,
|
|
files: Express.Multer.File[],
|
|
): Promise<{ booking: Booking; warnings: string[] }> {
|
|
const warnings: string[] = [];
|
|
|
|
const allowConsolidation = this.resolveConsolidation(
|
|
dto.containers,
|
|
dto.allowConsolidation,
|
|
);
|
|
|
|
const priorityScore = this.calculatePriorityScore(
|
|
dto.paymentCurrency,
|
|
dto.serviceType,
|
|
);
|
|
|
|
const overweightWarnings = this.checkOverweight(
|
|
dto.containers,
|
|
dto.tradeDirection,
|
|
);
|
|
warnings.push(...overweightWarnings);
|
|
|
|
const wagonCount = this.calculateWagonCount(dto.containers);
|
|
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
|
|
|
const booking = await this.bookingsRepository.create({
|
|
...dto,
|
|
scheduledDate: new Date(dto.scheduledDate),
|
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
|
status: "DRAFT",
|
|
allowConsolidation,
|
|
priorityScore,
|
|
});
|
|
|
|
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);
|
|
warnings.push('File upload failed — booking was created without attached files.');
|
|
}
|
|
}
|
|
|
|
return { booking, warnings };
|
|
}
|
|
|
|
/** Update a draft booking. */
|
|
async update(
|
|
id: string,
|
|
dto: UpdateBookingDto,
|
|
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");
|
|
}
|
|
|
|
const warnings: string[] = [];
|
|
const updates: Record<string, unknown> = { ...dto };
|
|
|
|
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);
|
|
|
|
// Re-evaluate consolidation if containers changed
|
|
const containers = dto.containers ?? existing.containers ?? [];
|
|
updates.allowConsolidation = this.resolveConsolidation(
|
|
containers,
|
|
dto.allowConsolidation,
|
|
);
|
|
|
|
// Recalculate priority
|
|
const currency = dto.paymentCurrency ?? existing.paymentCurrency;
|
|
const serviceType = dto.serviceType ?? existing.serviceType;
|
|
updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
|
|
|
|
// Overweight check
|
|
const direction = dto.tradeDirection ?? existing.tradeDirection;
|
|
const overweightWarnings = this.checkOverweight(containers, direction);
|
|
warnings.push(...overweightWarnings);
|
|
|
|
if (files.length > 0) {
|
|
await this.filesService.uploadMany(id, "bookings", files);
|
|
}
|
|
|
|
const booking = await this.bookingsRepository.update(id, updates);
|
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
|
|
|
return { booking, warnings };
|
|
}
|
|
|
|
/** Return a paginated list of bookings matching the filter. */
|
|
async findAll(
|
|
filter: FilterBookingDto,
|
|
): Promise<{ items: Booking[]; total: number }> {
|
|
const page = filter.page ?? 1;
|
|
const pageSize = filter.pageSize ?? 20;
|
|
|
|
const where: Record<string, unknown> = {};
|
|
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.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
|
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
|
if (filter.freightType) where.freightType = filter.freightType;
|
|
if (filter.allowConsolidation !== undefined)
|
|
where.allowConsolidation = filter.allowConsolidation;
|
|
if (filter.consolidationPaired === "true")
|
|
where.consolidationPartnerId = Not(IsNull());
|
|
else if (filter.consolidationPaired === "false")
|
|
where.consolidationPartnerId = IsNull();
|
|
|
|
const sortField = filter.sortBy ?? "createdAt";
|
|
const sortDir = filter.sortOrder ?? "DESC";
|
|
|
|
const [items, total] = await this.bookingsRepository.findAndCount({
|
|
where,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
order: { [sortField]: sortDir },
|
|
});
|
|
return { items, total };
|
|
}
|
|
|
|
/** Get a single booking by ID with files, throwing if not found. */
|
|
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("/");
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
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;
|
|
|
|
switch (action) {
|
|
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":
|
|
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);
|
|
default:
|
|
throw new BadRequestException(`Unknown action: ${action}`);
|
|
}
|
|
}
|
|
|
|
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */
|
|
private async handleSubmit(booking: Booking): Promise<Booking> {
|
|
this.assertStatus(booking, ["DRAFT"]);
|
|
const isBulk =
|
|
booking.freightType === "BULK" ||
|
|
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
|
|
const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF";
|
|
const updated = await this.bookingsRepository.update(booking.id, {
|
|
status: nextStatus,
|
|
} as never);
|
|
return updated!;
|
|
}
|
|
|
|
/** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */
|
|
private async handleApproveStaff(
|
|
booking: Booking,
|
|
actorId?: string,
|
|
): Promise<Booking> {
|
|
this.assertStatus(booking, ["PENDING_LINE_STAFF"]);
|
|
if (!actorId)
|
|
throw new BadRequestException("actorId is required for APPROVE_STAFF");
|
|
|
|
// Line staff cannot approve bulk
|
|
if (
|
|
booking.freightType === "BULK" ||
|
|
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS
|
|
) {
|
|
throw new BadRequestException(
|
|
"Line staff cannot approve bulk or high-volume bookings",
|
|
);
|
|
}
|
|
|
|
const updated = await this.bookingsRepository.update(booking.id, {
|
|
status: "APPROVED_PENDING_SIGNATURE",
|
|
approvedByStaffId: actorId,
|
|
approvedByStaffAt: new Date(),
|
|
} 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 isBulk =
|
|
booking.freightType === "BULK" ||
|
|
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
|
|
const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED";
|
|
|
|
const updated = await this.bookingsRepository.update(booking.id, {
|
|
status: nextStatus,
|
|
signedByDirectorId: actorId,
|
|
signedByDirectorAt: 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");
|
|
|
|
const updated = await this.bookingsRepository.update(booking.id, {
|
|
status: "SIGNED",
|
|
signedByCeoId: actorId,
|
|
signedByCeoAt: 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");
|
|
|
|
const updated = await this.bookingsRepository.update(booking.id, {
|
|
status: "CANCELLED",
|
|
} 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");
|
|
|
|
const updated = await this.bookingsRepository.update(booking.id, {
|
|
status: "CANCELLED",
|
|
} as never);
|
|
return updated!;
|
|
}
|
|
|
|
/** ACTIVATE: SIGNED → ACTIVE. */
|
|
private async handleActivate(booking: Booking): Promise<Booking> {
|
|
this.assertStatus(booking, ["SIGNED"]);
|
|
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",
|
|
endDate: new Date(),
|
|
} as never);
|
|
return updated!;
|
|
}
|
|
|
|
/** Guard: ensure current status is one of the allowed values. */
|
|
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(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── consolidation ────────────────────────────────────────────────────
|
|
|
|
/** Request consolidation — auto-pair if a partner exists, else queue. */
|
|
async requestConsolidation(id: string): Promise<{
|
|
booking: Booking;
|
|
partner: Booking | null;
|
|
paired: boolean;
|
|
}> {
|
|
const booking = await this.findById(id);
|
|
|
|
if (!booking.allowConsolidation) {
|
|
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) {
|
|
throw new BadRequestException(
|
|
"Only bookings with odd-quantity 20FT containers need consolidation",
|
|
);
|
|
}
|
|
|
|
if (booking.consolidationPartnerId) {
|
|
throw new ConflictException("Booking is already paired for consolidation");
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
// No partner found — enter queue
|
|
await this.bookingsRepository.update(booking.id, {
|
|
status: "PENDING_CONSOLIDATION",
|
|
} as never);
|
|
const updated = await this.findById(id);
|
|
return { booking: updated, partner: null, paired: false };
|
|
}
|
|
|
|
/** Remove consolidation pairing. */
|
|
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");
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/** Get consolidation details for a booking. */
|
|
async getConsolidationDetails(id: string): Promise<{
|
|
booking: Booking;
|
|
partner: Booking | null;
|
|
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
|
}> {
|
|
const booking = await this.findById(id);
|
|
|
|
if (!booking.consolidationPartnerId) {
|
|
return { booking, partner: null, splitBilling: null };
|
|
}
|
|
|
|
const partner = await this.findById(booking.consolidationPartnerId);
|
|
const splitBilling = {
|
|
bookingShare: booking.totalAmount,
|
|
partnerShare: partner.totalAmount,
|
|
};
|
|
|
|
return { booking, partner, splitBilling };
|
|
}
|
|
}
|