Files
edr-platform/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
2026-05-26 09:24:47 +03:00

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 };
}
}