diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 86428d44c..ba9910fcd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -24,7 +24,8 @@ export class BookingsRepository extends BaseRepository { return this.repository.findOne({ where: { allowConsolidation: true, - containerType: "20FT", + // Check if containers JSONB contains at least one 20FT entry with odd qty + containers: Not(IsNull()), originStation: booking.originStation, destinationStation: booking.destinationStation, tradeDirection: booking.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 138902662..57a5b2bd1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -35,12 +35,16 @@ export class BookingsService { /** Resolve auto-consolidation flag. */ private resolveConsolidation( - containerType: string, - containerQuantity: number, + containers: Array<{ type: string; qty: number }> | undefined | null, explicit?: boolean, ): boolean { 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; } @@ -55,32 +59,42 @@ export class BookingsService { /** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */ private calculateWagonCount( - containerType: string, - containerQuantity: number, + containers: Array<{ type: string; qty: number }>, ): number { - if (containerType === "40FT") return containerQuantity; - return Math.ceil(containerQuantity / 2); + 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 limit and return a warning if exceeded. */ + /** Check per-container weight limits and return warnings if exceeded. */ private checkOverweight( - containerType: string, - vgmPerUnit: number, + containers: Array<{ type: string; vgm: number }>, tradeDirection: string, - ): string | null { - if (containerType === "40FT" && vgmPerUnit > WEIGHT_LIMITS.ANY_40FT) { - return `40FT container VGM ${vgmPerUnit}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`; - } - if (containerType === "20FT") { - const limit = - tradeDirection === "IMPORT" - ? WEIGHT_LIMITS.IMPORT_20FT - : WEIGHT_LIMITS.EXPORT_20FT; - if (vgmPerUnit > limit) { - return `20FT ${tradeDirection} container VGM ${vgmPerUnit}t exceeds limit of ${limit}t`; + ): 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 null; + return warnings; } /** Build file metadata from uploaded files and upload to MinIO. */ @@ -117,8 +131,7 @@ export class BookingsService { const warnings: string[] = []; const allowConsolidation = this.resolveConsolidation( - dto.containerType, - dto.containerQuantity, + dto.containers, dto.allowConsolidation, ); @@ -127,17 +140,13 @@ export class BookingsService { dto.serviceType, ); - const overweightWarning = this.checkOverweight( - dto.containerType, - dto.containerVgmPerUnit, + const overweightWarnings = this.checkOverweight( + dto.containers, dto.tradeDirection, ); - if (overweightWarning) warnings.push(overweightWarning); + warnings.push(...overweightWarnings); - const wagonCount = this.calculateWagonCount( - dto.containerType, - dto.containerQuantity, - ); + const wagonCount = this.calculateWagonCount(dto.containers); warnings.push(`Estimated wagons required: ${wagonCount}`); const booking = await this.bookingsRepository.create({ @@ -184,12 +193,10 @@ export class BookingsService { if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); - // Re-evaluate consolidation if container fields changed - const containerType = dto.containerType ?? existing.containerType; - const containerQuantity = dto.containerQuantity ?? existing.containerQuantity; + // Re-evaluate consolidation if containers changed + const containers = dto.containers ?? existing.containers ?? []; updates.allowConsolidation = this.resolveConsolidation( - containerType, - containerQuantity, + containers, dto.allowConsolidation, ); @@ -199,10 +206,9 @@ export class BookingsService { updates.priorityScore = this.calculatePriorityScore(currency, serviceType); // Overweight check - const vgm = dto.containerVgmPerUnit ?? existing.containerVgmPerUnit; const direction = dto.tradeDirection ?? existing.tradeDirection; - const ow = this.checkOverweight(containerType, vgm, direction); - if (ow) warnings.push(ow); + const overweightWarnings = this.checkOverweight(containers, direction); + warnings.push(...overweightWarnings); // Merge documents - upload new files to MinIO if (files.length > 0) { @@ -231,7 +237,6 @@ export class BookingsService { if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; if (filter.freightType) where.freightType = filter.freightType; - if (filter.containerType) where.containerType = filter.containerType; if (filter.allowConsolidation !== undefined) where.allowConsolidation = filter.allowConsolidation; if (filter.consolidationPaired === "true") @@ -474,14 +479,18 @@ export class BookingsService { if (!booking.allowConsolidation) { throw new BadRequestException("Booking is not eligible for consolidation"); } - if (booking.containerType !== "20FT") { - throw new BadRequestException("Only 20FT containers can be consolidated"); - } - if (booking.containerQuantity % 2 === 0) { + + // 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 odd-quantity 20FT bookings need consolidation", + "Only bookings with odd-quantity 20FT containers need consolidation", ); } + if (booking.consolidationPartnerId) { throw new ConflictException("Booking is already paired for consolidation"); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index f862dd0b2..0f29adc1a 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform } from "class-transformer"; +import { Transform, Type } from "class-transformer"; import { + IsArray, IsBoolean, IsDateString, IsIn, @@ -10,6 +11,7 @@ import { IsString, IsUUID, Min, + ValidateNested, } from "class-validator"; const BOOKING_STATUSES = [ @@ -47,6 +49,24 @@ export { 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 { // ── core ───────────────────────────────────────────────────────────── @ApiProperty({ description: "Unique booking reference" }) @@ -177,26 +197,16 @@ export class CreateBookingDto { @IsString() financialTerms?: string; - // ── container ──────────────────────────────────────────────────────── - @ApiProperty({ enum: CONTAINER_TYPES }) - @IsIn([...CONTAINER_TYPES]) - containerType!: string; - - @ApiProperty({ minimum: 1 }) - @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; + // ── containers ──────────────────────────────────────────────────────── + @ApiProperty({ type: [ContainerItem], description: "Array of container specifications" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContainerItem) + containers!: ContainerItem[]; @ApiPropertyOptional({ 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() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index cc096d40c..b5b23a80d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -5,7 +5,6 @@ import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class import { BOOKING_STATUSES, CONTRACT_TYPES, - CONTAINER_TYPES, FREIGHT_TYPES, PAYMENT_CURRENCIES, SERVICE_TYPES, @@ -48,11 +47,6 @@ export class FilterBookingDto { @IsIn([...FREIGHT_TYPES]) freightType?: string; - @ApiPropertyOptional({ enum: CONTAINER_TYPES }) - @IsOptional() - @IsIn([...CONTAINER_TYPES]) - containerType?: string; - @ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c256ef099..8c7492859 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -105,20 +105,9 @@ export class Booking extends BaseEntity { @Column({ name: "version_number", type: "int", default: 1 }) versionNumber!: number; - // ── container ────────────────────────────────────────────────────────── - @Column({ name: "container_type", type: "varchar", length: 10 }) - containerType!: string; - - @Column({ name: "container_quantity", type: "int" }) - containerQuantity!: number; - - @Column({ - name: "container_vgm_per_unit", - type: "numeric", - precision: 10, - scale: 3, - }) - containerVgmPerUnit!: 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 })