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, MinLength, 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; } 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({ format: 'uuid', description: 'Container type for CONTAINER contracts; omit for BULK', }) @IsOptional() @IsUUID() containerTypeId?: string; @ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 }) @IsNumber() @Min(0) @Transform(({ value }) => Number(value)) quantity!: number; @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; @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) @ValidateIf((o) => o.isGovernment === true) @IsString() @MinLength(2) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) governmentInstitution?: string; @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) @ValidateIf((o) => o.isGovernment !== true) @IsOptional() @IsUUID() companyId?: 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 day the customer wants to ship (the pool day key). Required for one-time * bookings; omitted for general contracts, which pick the date per order. */ @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') @IsDateString() scheduledDate?: 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; @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; @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[]; }