Files
edr-platform/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
Hagernesh 312c1f1da3 feat(warehouses): backoffice UI for container stack positions
Surfaces the physical stack/slot model in the staff app.

- ZoneLayoutModal: stacks drawn level by level with occupancy colours,
  configured-vs-built-vs-occupied counts, and stack create/delete plus
  block/reserve/free on empty levels
- SlotPicker in the store and move modals, offering only the next
  fillable level of each stack so the form cannot suggest a position
  the API will refuse
- move modal warns when a container is buried, lists the blockers, and
  disables the action instead of firing a 409
- fix: move() now asserts accessibility server-side, matching release —
  both are exits from a stack
2026-08-29 06:39:29 +00:00

306 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
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;
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsNotEmpty({ message: 'sealNumber is required' })
@MaxLength(64)
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({
minimum: 1,
description:
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
'Required when the cargo type is measured by wagons, ignored otherwise.',
})
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
requestedWagons?: number;
@ApiPropertyOptional({
description: 'What the containers carry — captured per booking (container freight).',
})
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({
maxLength: 200,
description:
'Customs clearing agent name. Required at completion of a without-customs ' +
'import/export booking (the service enforces it); ignored on customs contracts.',
})
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
@IsOptional()
@IsEmail()
@MaxLength(200)
customsClearingAgentEmail?: string;
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
@IsOptional()
@IsString()
@MaxLength(50)
customsClearingAgentPhone?: 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 bookings 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;
}