import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength, ValidateIf, ValidateNested, } from 'class-validator'; import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; // Canonical UPPERCASE — everything downstream (booking gating, pricing // surcharge, GL/portal booking forms) compares contract.equipmentReturn // against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized. const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; export { CONTRACT_KINDS, TRADE_DIRECTIONS, FREIGHT_TYPES, PAYMENT_CURRENCIES, EQUIPMENT_RETURNS, }; /** One cargo-scope row — a container size OR a bulk commodity. NO quantities. */ export class CreateContractCargoScopeDto { @ApiPropertyOptional({ description: '"20ft" | "40ft"; omit for bulk' }) @IsOptional() @IsString() @MaxLength(10) containerSize?: string | null; @ApiPropertyOptional({ format: 'uuid', description: 'FK to cargo_types.id' }) @IsOptional() @IsUUID() cargoTypeId?: string | null; @ApiPropertyOptional({ maxLength: 200 }) @IsOptional() @IsString() @MaxLength(200) cargoFreeText?: string | null; @ApiPropertyOptional({ description: 'GENERAL only: total bookable quantity for this line (containers per size, or tons/items for bulk). Omit for uncapped.', minimum: 1, }) @IsOptional() @IsNumber() @Min(1) @Transform(({ value }) => (value == null || value === '' ? null : Number(value))) quantityCap?: number | null; } /** A contracted lane (origin → destination). Routes carry NO quantity. */ export class CreateContractRouteInputDto { @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 billing distance (km); null for rail-only.', minimum: 0, }) @IsOptional() @IsNumber() @Min(0) @Transform(({ value }) => value === undefined || value === null || value === '' ? undefined : Number(value), ) km?: number; @ApiPropertyOptional({ minimum: 0 }) @IsOptional() @IsNumber() @Min(0) @Transform(({ value }) => value === undefined || value === null || value === '' ? undefined : Number(value), ) sortOrder?: number; } export class CreateContractDto { @ApiPropertyOptional({ description: 'Unique contract reference (auto-generated if omitted)' }) @IsOptional() @IsString() @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) reference?: string; @ApiPropertyOptional({ description: 'Staff only: government contract 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', description: 'Explicit company profile to stamp the contract to (a forwarder contract); ' + 'commercial contracts otherwise auto-resolve from trade direction.', }) @IsOptional() @IsUUID() companyProfileId?: string; @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @IsIn([...CONTRACT_KINDS]) contractKind!: string; @ApiPropertyOptional({ format: 'uuid', description: 'FK to contracts.id when renewing' }) @IsOptional() @IsUUID() @Transform(({ value }) => (value === '' || value == null ? undefined : value)) renewalOfId?: string; @ApiProperty({ enum: TRADE_DIRECTIONS }) @IsIn([...TRADE_DIRECTIONS]) tradeDirection!: string; @ApiProperty({ enum: FREIGHT_TYPES }) @IsIn([...FREIGHT_TYPES]) freightType!: string; @ApiProperty({ format: 'uuid', description: 'FK to service_types.id' }) @IsUUID() serviceTypeId!: string; @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; @ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) customsClearingEnabled?: boolean; @ApiPropertyOptional({ maxLength: 200 }) @IsOptional() @IsString() @MaxLength(200) customsClearingAgent?: string; @ApiPropertyOptional({ enum: EQUIPMENT_RETURNS }) @IsOptional() @Transform(({ value }) => typeof value === 'string' ? value.toUpperCase() : value, ) @IsIn([...EQUIPMENT_RETURNS]) equipmentReturn?: 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({ default: false, description: 'Sets contracts.is_hazardous' }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; @ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' }) @IsOptional() @IsBoolean() @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @IsOptional() @IsString() @MaxLength(20) contractType?: string; @ApiProperty({ type: [CreateContractCargoScopeDto], description: 'Cargo scope rows. CONTAINER: ≥1 size row. BULK: exactly one cargo-type row.', }) @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => CreateContractCargoScopeDto) cargoScope!: CreateContractCargoScopeDto[]; @ApiProperty({ type: [CreateContractRouteInputDto], description: 'Contracted lanes. ONE_TIME: exactly 1. GENERAL: 1..N.', }) @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => CreateContractRouteInputDto) routes!: CreateContractRouteInputDto[]; }