Files
edr-platform/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
2026-07-31 10:26:17 +00:00

425 lines
12 KiB
TypeScript

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
Validate,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
BOOKING_TYPES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
};
export class CreateBookingContainerDto {
@ApiProperty({ format: 'uuid', description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ description: 'Quantity of containers', minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons!: number;
@ApiPropertyOptional({
description: 'How many of this line are hazardous (0..quantity)',
minimum: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
hazardousQuantity?: number;
@ApiPropertyOptional({
description: 'How many of this line are refrigerated (0..quantity)',
minimum: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
@ApiPropertyOptional({
description:
'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)',
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
containerNumbers?: string[];
}
/**
* A contracted route (lane) of a general contract — a pure origin→destination
* pair the contract covers. Routes carry NO quantity; the contract draws from a
* single shared pool (the container quantities / bulk total on the booking). An
* order picks one lane (for scheduling + road billing) and draws from that pool.
*/
export class CreateContractRouteDto {
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiPropertyOptional({
description: 'Road distance (km) for this route; used to bill road orders.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) =>
value === undefined || value === null || value === '' ? undefined : Number(value),
)
km?: number;
}
export class CreateBookingDto {
/** Class-level freight shape check (not a request field). */
@Validate(BookingFreightShapeConstraint)
freightShapeValidation?: boolean;
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
// @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ description: 'Staff only: government booking flag' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
/** @deprecated Government bookings now bill to a real government company. */
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
})
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainId?: string;
/**
* Staff-only manual pin to a specific train. Customers omit this — they pick a
* DAY via {@link scheduledDate} and the batch engine assigns a train within
* that (route, day) pool. When provided, the schedule must be OPEN and on the
* booking route.
*/
@ApiPropertyOptional({
format: 'uuid',
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({
enum: BOOKING_TYPES,
default: 'ONE_TIME',
description:
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
})
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
/**
* The BINDING shipment day (the pool day key), validated against open train
* departures. Set later at the operation-request step — NOT at booking
* creation. Optional here; staff may still pin it directly.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@IsOptional()
@IsDateString()
scheduledDate?: string;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@IsOptional()
@IsDateString()
estimatedShipmentDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
previousContractId?: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLat?: number;
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLng?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLat?: number;
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLng?: number;
@ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
customsClearingEnabled?: boolean;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' })
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
/**
* GENERAL_CONTRACT only: the routes this contract reserves quantity across.
* Each entry has its own origin/destination and quantity; the first entry also
* matches the booking's originYardId/destinationYardId. Omitted for one-time
* bookings, which use the single origin/destination above.
*/
@ApiPropertyOptional({ type: [CreateContractRouteDto] })
@ValidateIf((o) => o.bookingType === 'GENERAL_CONTRACT')
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateContractRouteDto)
routes?: CreateContractRouteDto[];
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for BULK; must be omitted for CONTAINER',
})
@ValidateIf((o) => o.freightType === 'BULK')
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' })
@IsOptional()
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count.
* Omit for PER_TON bulk and container freight.
*/
@ApiPropertyOptional({
minimum: 0,
description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count',
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value == null ? undefined : Number(value)))
bulkTotalWeightTons?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
/**
* Booking-level refrigerated flag. For bulk freight this is the customer's
* reefer choice (containers derive reefer from the container type instead).
* ORed with per-container reefer when the REEFER surcharge is evaluated.
*/
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
/**
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
*/
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
bulkHazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
bulkReeferQuantity?: number;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
pnrCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
financialTerms?: string;
@ApiPropertyOptional({
type: [CreateBookingContainerDto],
description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
})
@ValidateIf((o) => o.freightType === 'CONTAINER')
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerDto)
containers?: CreateBookingContainerDto[];
}