import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested, } from 'class-validator'; import { PAYMENT_CURRENCIES } from './create-contract.dto'; /** Per-shipment equipment return — "NA" stays contract-level only. */ const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @IsString() @Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value)) @Matches(/^[A-Z]{4}\d{7}$/, { message: 'containerNumber must match ISO container format, e.g. ABCD1234567', }) containerNumber!: string; @ApiPropertyOptional() @IsOptional() @IsString() sealNumber?: string; @ApiProperty({ description: 'VGM in tons', minimum: 0 }) @IsNumber() @Min(0) @Transform(({ value }) => Number(value)) vgmTons!: number; @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; @ApiPropertyOptional({ default: false, description: 'This container ships back empty (equipment return).', }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReturn?: boolean; } export class CreateBookingContainerLineDto { @ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' }) @IsString() containerSize!: string; @ApiProperty({ minimum: 1 }) @IsInt() @Min(1) @Transform(({ value }) => Number(value)) quantity!: number; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) @Transform(({ value }) => Number(value)) hazardousQuantity?: number; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) @Transform(({ value }) => Number(value)) reeferQuantity?: number; @ApiPropertyOptional({ minimum: 0, description: 'How many units of this line ship with empty-container return (≤ quantity). ' + 'Only allowed when the contract was created WITH_RETURN (container freight).', }) @IsOptional() @IsInt() @Min(0) @Transform(({ value }) => Number(value)) returnQuantity?: number; @ApiProperty({ type: [CreateContainerUnitDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => CreateContainerUnitDto) units!: CreateContainerUnitDto[]; } export class CreateBulkLineDto { @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() cargoTypeId?: string | null; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsNumber() @Min(0) @Transform(({ value }) => Number(value)) cargoWeightTons?: number; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) @Transform(({ value }) => Number(value)) itemCount?: number; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) @Transform(({ value }) => Number(value)) hazardousQuantity?: number; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsInt() @Min(0) @Transform(({ value }) => Number(value)) reeferQuantity?: number; } /** Shipment booking created under a contract (Path A customer, Path B GL ET). */ export class CreateBookingUnderContractDto { @ApiPropertyOptional({ format: 'uuid', description: 'Required for GENERAL multi-route contracts; ONE_TIME auto-selected.', }) @IsOptional() @IsUUID() contractRouteId?: string; /** * The contract quotes in USD; the customer picks the billing currency here. * Omitted → the contract's own currency (USD for contracts created under the * current rule, the grandfathered currency for older ones). Intercity is * forced to ETB by the service regardless of what is sent. */ @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES, description: 'Billing currency for this shipment. Intercity is always ETB.', }) @IsOptional() @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency?: string; @ApiPropertyOptional({ description: 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', example: '2026-07-15', }) @IsOptional() @IsDateString() scheduledDate?: string; @ApiPropertyOptional({ description: 'EXPORT rail only: the specific train (schedule id) picked from ' + 'GET /bookings/:id/export-trains for the shipment day. The reserve path ' + 'locks onto this train; 409 when it no longer fits. Ignored otherwise.', }) @IsOptional() @IsUUID() trainScheduleId?: string; @ApiPropertyOptional({ enum: SHIPMENT_EQUIPMENT_RETURNS, description: 'Per-shipment equipment return override; omitted → the contract default applies.', }) @IsOptional() @IsIn([...SHIPMENT_EQUIPMENT_RETURNS]) equipmentReturn?: string; @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CreateBookingContainerLineDto) containers?: CreateBookingContainerLineDto[]; @ApiPropertyOptional({ type: [CreateBulkLineDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; @ApiPropertyOptional({ description: 'What the containers carry — captured per booking (container freight).', }) @IsOptional() @IsString() cargoFreeText?: string; @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; /** * Internal: set by the manual GL pair-completion path, never by a client. * Suppresses the automatic wagon-consolidation gate for this completion * because the caller links the shared wagon itself. Excluded from the public * schema so a client cannot set it to bypass the gate on a lone booking. */ @ApiHideProperty() @IsOptional() @IsBoolean() skipAutoConsolidation?: boolean; } /** * Complete an odd-20ft customs booking together with the partner booking GL * picked to share its wagon. Each half carries its own full completion payload — * the two bookings stay separately priced and separately invoiced, they only * share the wagon. */ export class CompleteConsolidatedPairDto { @ApiProperty({ format: 'uuid', description: 'The booking chosen to share this booking’s wagon.', }) @IsUUID() partnerBookingId!: string; @ApiProperty({ type: CreateBookingUnderContractDto, description: 'Completion payload for the booking in the URL.', }) @ValidateNested() @Type(() => CreateBookingUnderContractDto) booking!: CreateBookingUnderContractDto; @ApiProperty({ type: CreateBookingUnderContractDto, description: 'Completion payload for the partner booking.', }) @ValidateNested() @Type(() => CreateBookingUnderContractDto) partner!: CreateBookingUnderContractDto; }