feat(warehouses): track physical container stack and slot positions

Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.

- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
  stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
  unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
  accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
This commit is contained in:
Hagernesh
2026-08-28 16:07:50 +00:00
parent 9de4e9e238
commit a6ea1a48ac
28 changed files with 2313 additions and 13 deletions

View File

@@ -14,6 +14,14 @@ export class MoveInventoryDto {
@IsUUID()
zoneId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Exact physical slot in the destination zone. Container yards only.',
})
@IsOptional()
@IsUUID()
slotId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
export class FindAvailableSlotDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Narrow the search to one zone.' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'BOTH'], description: 'Null/BOTH matches any yard direction.' })
@IsOptional()
@IsIn(['IMPORT', 'EXPORT', 'BOTH'])
direction?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
}
export class AssignSlotDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Target slot. Omit to let the placement engine pick the lowest free level.',
})
@IsOptional()
@IsUUID()
slotId?: string;
}

View File

@@ -22,6 +22,15 @@ export class StoreInventoryDto {
@IsUUID()
zoneId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Exact physical slot. Container yards only; omit to let the placement engine pick the lowest free level.',
})
@IsOptional()
@IsUUID()
slotId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -0,0 +1,120 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import {
DEFAULT_MAX_STACK_HEIGHT,
WAREHOUSE_ZONE_STACK_STATUSES,
WarehouseZoneStackStatus,
} from '../entities/warehouse-zone-stack.entity';
import {
WAREHOUSE_ZONE_SLOT_STATUSES,
WarehouseZoneSlotStatus,
} from '../entities/warehouse-zone-slot.entity';
/** Nobody stacks boxes this high; the cap is here to catch a typo'd 30. */
const MAX_SUPPORTED_STACK_HEIGHT = 10;
export class CreateWarehouseZoneStackDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiProperty({ example: 'ZA-001' })
@IsString()
@MaxLength(40)
code!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
name?: string;
@ApiPropertyOptional({ description: 'Physical row label' })
@IsOptional()
@IsString()
@MaxLength(20)
row?: string;
@ApiPropertyOptional({ description: 'Physical bay label' })
@IsOptional()
@IsString()
@MaxLength(20)
bay?: string;
@ApiPropertyOptional({ description: 'Physical position label' })
@IsOptional()
@IsString()
@MaxLength(20)
position?: string;
@ApiPropertyOptional({
default: DEFAULT_MAX_STACK_HEIGHT,
description: 'One slot is generated per level, 1 to this height.',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SUPPORTED_STACK_HEIGHT)
maxStackHeight?: number;
}
export class UpdateWarehouseZoneStackDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(40)
code?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
row?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
bay?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
position?: string;
@ApiPropertyOptional({ description: 'Raising it adds slots; lowering it removes the empty top levels.' })
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SUPPORTED_STACK_HEIGHT)
maxStackHeight?: number;
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STACK_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_ZONE_STACK_STATUSES)
status?: WarehouseZoneStackStatus;
}
export class UpdateWarehouseZoneSlotDto {
@ApiPropertyOptional({
enum: WAREHOUSE_ZONE_SLOT_STATUSES,
description: 'Operator intent only. OCCUPIED is derived from inventory and cannot be set here.',
})
@IsOptional()
@IsEnum(WAREHOUSE_ZONE_SLOT_STATUSES)
status?: WarehouseZoneSlotStatus;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}