mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat(bookings): support multiple containers per booking via JSONB array
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user