feat(bookings): support multiple containers per booking via JSONB array

This commit is contained in:
marshal
2026-05-23 11:08:35 +03:00
parent 2064e6efda
commit ae317540c1
5 changed files with 88 additions and 85 deletions

View File

@@ -24,7 +24,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ return this.repository.findOne({
where: { where: {
allowConsolidation: true, allowConsolidation: true,
containerType: "20FT", // Check if containers JSONB contains at least one 20FT entry with odd qty
containers: Not(IsNull()),
originStation: booking.originStation, originStation: booking.originStation,
destinationStation: booking.destinationStation, destinationStation: booking.destinationStation,
tradeDirection: booking.tradeDirection, tradeDirection: booking.tradeDirection,

View File

@@ -35,12 +35,16 @@ export class BookingsService {
/** Resolve auto-consolidation flag. */ /** Resolve auto-consolidation flag. */
private resolveConsolidation( private resolveConsolidation(
containerType: string, containers: Array<{ type: string; qty: number }> | undefined | null,
containerQuantity: number,
explicit?: boolean, explicit?: boolean,
): boolean { ): boolean {
if (explicit === false) return false; if (explicit === false) return false;
if (containerType === "20FT" && containerQuantity % 2 !== 0) return true; 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; return explicit ?? false;
} }
@@ -55,32 +59,42 @@ export class BookingsService {
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */ /** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
private calculateWagonCount( private calculateWagonCount(
containerType: string, containers: Array<{ type: string; qty: number }>,
containerQuantity: number,
): number { ): number {
if (containerType === "40FT") return containerQuantity; return containers.reduce((total, container) => {
return Math.ceil(containerQuantity / 2); 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 limit and return a warning if exceeded. */ /** Check per-container weight limits and return warnings if exceeded. */
private checkOverweight( private checkOverweight(
containerType: string, containers: Array<{ type: string; vgm: number }>,
vgmPerUnit: number,
tradeDirection: string, tradeDirection: string,
): string | null { ): string[] {
if (containerType === "40FT" && vgmPerUnit > WEIGHT_LIMITS.ANY_40FT) { const warnings: string[] = [];
return `40FT container VGM ${vgmPerUnit}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`; for (const container of containers) {
} if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) {
if (containerType === "20FT") { warnings.push(
const limit = `40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`
tradeDirection === "IMPORT" );
? WEIGHT_LIMITS.IMPORT_20FT }
: WEIGHT_LIMITS.EXPORT_20FT; if (container.type === "20FT") {
if (vgmPerUnit > limit) { const limit =
return `20FT ${tradeDirection} container VGM ${vgmPerUnit}t exceeds limit of ${limit}t`; 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 null; return warnings;
} }
/** Build file metadata from uploaded files and upload to MinIO. */ /** Build file metadata from uploaded files and upload to MinIO. */
@@ -117,8 +131,7 @@ export class BookingsService {
const warnings: string[] = []; const warnings: string[] = [];
const allowConsolidation = this.resolveConsolidation( const allowConsolidation = this.resolveConsolidation(
dto.containerType, dto.containers,
dto.containerQuantity,
dto.allowConsolidation, dto.allowConsolidation,
); );
@@ -127,17 +140,13 @@ export class BookingsService {
dto.serviceType, dto.serviceType,
); );
const overweightWarning = this.checkOverweight( const overweightWarnings = this.checkOverweight(
dto.containerType, dto.containers,
dto.containerVgmPerUnit,
dto.tradeDirection, dto.tradeDirection,
); );
if (overweightWarning) warnings.push(overweightWarning); warnings.push(...overweightWarnings);
const wagonCount = this.calculateWagonCount( const wagonCount = this.calculateWagonCount(dto.containers);
dto.containerType,
dto.containerQuantity,
);
warnings.push(`Estimated wagons required: ${wagonCount}`); warnings.push(`Estimated wagons required: ${wagonCount}`);
const booking = await this.bookingsRepository.create({ const booking = await this.bookingsRepository.create({
@@ -184,12 +193,10 @@ export class BookingsService {
if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate); if (dto.endDate) updates.endDate = new Date(dto.endDate);
// Re-evaluate consolidation if container fields changed // Re-evaluate consolidation if containers changed
const containerType = dto.containerType ?? existing.containerType; const containers = dto.containers ?? existing.containers ?? [];
const containerQuantity = dto.containerQuantity ?? existing.containerQuantity;
updates.allowConsolidation = this.resolveConsolidation( updates.allowConsolidation = this.resolveConsolidation(
containerType, containers,
containerQuantity,
dto.allowConsolidation, dto.allowConsolidation,
); );
@@ -199,10 +206,9 @@ export class BookingsService {
updates.priorityScore = this.calculatePriorityScore(currency, serviceType); updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
// Overweight check // Overweight check
const vgm = dto.containerVgmPerUnit ?? existing.containerVgmPerUnit;
const direction = dto.tradeDirection ?? existing.tradeDirection; const direction = dto.tradeDirection ?? existing.tradeDirection;
const ow = this.checkOverweight(containerType, vgm, direction); const overweightWarnings = this.checkOverweight(containers, direction);
if (ow) warnings.push(ow); warnings.push(...overweightWarnings);
// Merge documents - upload new files to MinIO // Merge documents - upload new files to MinIO
if (files.length > 0) { if (files.length > 0) {
@@ -231,7 +237,6 @@ export class BookingsService {
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.freightType) where.freightType = filter.freightType; if (filter.freightType) where.freightType = filter.freightType;
if (filter.containerType) where.containerType = filter.containerType;
if (filter.allowConsolidation !== undefined) if (filter.allowConsolidation !== undefined)
where.allowConsolidation = filter.allowConsolidation; where.allowConsolidation = filter.allowConsolidation;
if (filter.consolidationPaired === "true") if (filter.consolidationPaired === "true")
@@ -474,14 +479,18 @@ export class BookingsService {
if (!booking.allowConsolidation) { if (!booking.allowConsolidation) {
throw new BadRequestException("Booking is not eligible for consolidation"); throw new BadRequestException("Booking is not eligible for consolidation");
} }
if (booking.containerType !== "20FT") {
throw new BadRequestException("Only 20FT containers can be consolidated"); // Check if any 20FT container has odd quantity
} const hasOdd20FT = booking.containers?.some(
if (booking.containerQuantity % 2 === 0) { (c) => c.type === "20FT" && c.qty % 2 !== 0
) ?? false;
if (!hasOdd20FT) {
throw new BadRequestException( throw new BadRequestException(
"Only odd-quantity 20FT bookings need consolidation", "Only bookings with odd-quantity 20FT containers need consolidation",
); );
} }
if (booking.consolidationPartnerId) { if (booking.consolidationPartnerId) {
throw new ConflictException("Booking is already paired for consolidation"); throw new ConflictException("Booking is already paired for consolidation");
} }

View File

@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer"; import { Transform, Type } from "class-transformer";
import { import {
IsArray,
IsBoolean, IsBoolean,
IsDateString, IsDateString,
IsIn, IsIn,
@@ -10,6 +11,7 @@ import {
IsString, IsString,
IsUUID, IsUUID,
Min, Min,
ValidateNested,
} from "class-validator"; } from "class-validator";
const BOOKING_STATUSES = [ const BOOKING_STATUSES = [
@@ -47,6 +49,24 @@ export {
CONTAINER_TYPES, CONTAINER_TYPES,
}; };
export class ContainerItem {
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
@IsIn([...CONTAINER_TYPES])
type!: string;
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
qty!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgm!: number;
}
export class CreateBookingDto { export class CreateBookingDto {
// ── core ───────────────────────────────────────────────────────────── // ── core ─────────────────────────────────────────────────────────────
@ApiProperty({ description: "Unique booking reference" }) @ApiProperty({ description: "Unique booking reference" })
@@ -177,26 +197,16 @@ export class CreateBookingDto {
@IsString() @IsString()
financialTerms?: string; financialTerms?: string;
// ── container ──────────────────────────────────────────────────────── // ── containers ────────────────────────────────────────────────────────
@ApiProperty({ enum: CONTAINER_TYPES }) @ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
@IsIn([...CONTAINER_TYPES]) @IsArray()
containerType!: string; @ValidateNested({ each: true })
@Type(() => ContainerItem)
@ApiProperty({ minimum: 1 }) containers!: ContainerItem[];
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
containerQuantity!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
containerVgmPerUnit!: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
default: false, default: false,
description: "Auto-set to true when containerType=20FT and odd quantity. User may override.", description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
}) })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -5,7 +5,6 @@ import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class
import { import {
BOOKING_STATUSES, BOOKING_STATUSES,
CONTRACT_TYPES, CONTRACT_TYPES,
CONTAINER_TYPES,
FREIGHT_TYPES, FREIGHT_TYPES,
PAYMENT_CURRENCIES, PAYMENT_CURRENCIES,
SERVICE_TYPES, SERVICE_TYPES,
@@ -48,11 +47,6 @@ export class FilterBookingDto {
@IsIn([...FREIGHT_TYPES]) @IsIn([...FREIGHT_TYPES])
freightType?: string; freightType?: string;
@ApiPropertyOptional({ enum: CONTAINER_TYPES })
@IsOptional()
@IsIn([...CONTAINER_TYPES])
containerType?: string;
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" }) @ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -105,20 +105,9 @@ export class Booking extends BaseEntity {
@Column({ name: "version_number", type: "int", default: 1 }) @Column({ name: "version_number", type: "int", default: 1 })
versionNumber!: number; versionNumber!: number;
// ── container ───────────────────────────────────────────────────────── // ── containers ─────────────────────────────────────────────────────────
@Column({ name: "container_type", type: "varchar", length: 10 }) @Column({ name: "containers", type: "jsonb", nullable: true })
containerType!: string; containers!: Array<{ type: string; qty: number; vgm: number }> | null;
@Column({ name: "container_quantity", type: "int" })
containerQuantity!: number;
@Column({
name: "container_vgm_per_unit",
type: "numeric",
precision: 10,
scale: 3,
})
containerVgmPerUnit!: number;
// ── approval ─────────────────────────────────────────────────────────── // ── approval ───────────────────────────────────────────────────────────
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true }) @Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })