diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 0c6b1c727..79e350cc7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,6 +18,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", diff --git a/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts new file mode 100644 index 000000000..38341a1f1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts @@ -0,0 +1,128 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Physical container positions below the zone: a stack is the ground footprint, + * a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory. + * + * Everything is additive and nullable. Existing inventory keeps warehouse / + * yard / zone as its only location and stays valid — nothing is backfilled, + * because no one can know where a box already in the yard is actually stacked. + * + * Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot` + * makes the inventory row the single source of truth: one live placement per + * slot, enforced by Postgres. Its status list must stay in step with + * `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts. + */ +export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE, + code varchar(40) NOT NULL, + name varchar(160), + "row" varchar(20), + bay varchar(20), + "position" varchar(20), + max_stack_height int NOT NULL DEFAULT 3, + status varchar(16) NOT NULL DEFAULT 'ACTIVE', + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1) + ) + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`, + ); + // Partial: a soft-deleted stack must not block reusing its code. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code + ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE, + level int NOT NULL, + status varchar(16) NOT NULL DEFAULT 'AVAILABLE', + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1) + ) + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level + ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`, + ); + + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`, + ); + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`, + ); + + // Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS. + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.warehouse_inventory + ADD CONSTRAINT fk_warehouse_inventory_stack + FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.warehouse_inventory + ADD CONSTRAINT fk_warehouse_inventory_slot + FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`, + ); + + // One live container per slot. Statuses past the yard gate (LOADED, + // DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position + // without any exit path having to clear the column. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot + ON freight.warehouse_inventory (slot_id) + WHERE deleted_at IS NULL + AND slot_id IS NOT NULL + AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`); + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`, + ); + await queryRunner.query( + `ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`, + ); + await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`); + await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts b/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts new file mode 100644 index 000000000..6a9d2ccde --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts @@ -0,0 +1,222 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { WarehousePlacementService } from './warehouse-placement.service'; +import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service'; + +/** + * The physical rules a yard operator would recognise: nothing floats above an + * empty level, a slot holds one box, and the ids a client sends are only + * believed after the whole chain has been resolved server-side. + */ + +const CHAIN = { + slotId: 'slot-2', + slotStatus: 'AVAILABLE', + slotIsActive: true, + level: 2, + stackId: 'stack-1', + stackCode: 'ZA-001', + stackStatus: 'ACTIVE', + stackIsActive: true, + maxStackHeight: 3, + zoneId: 'zone-1', + zoneCode: 'L1-O-A-ZA', + zoneType: 'CONTAINER_ZONE', + zoneStatus: 'ACTIVE', + zoneIsActive: true, + yardId: 'yard-1', + yardCode: 'L1-O-A', + yardType: 'CONTAINER_YARD', + yardDirection: null, + yardStatus: 'ACTIVE', + yardIsActive: true, + warehouseId: 'wh-1', + warehouseCode: 'L1-OPEN', + warehouseStatus: 'ACTIVE', + warehouseIsActive: true, +}; + +/** A placement service whose slot chain and stack occupancy are dictated by the test. */ +function makePlacement(chain: Partial, occupiedLevels: number[], slotTakenBy: string | null = null) { + const service = Object.create(WarehousePlacementService.prototype) as Record; + service.resolveSlot = jest.fn().mockResolvedValue({ ...CHAIN, ...chain }); + service.occupiedLevels = jest.fn().mockResolvedValue(occupiedLevels); + service.em = () => ({ query: jest.fn().mockResolvedValue(slotTakenBy ? [{ id: slotTakenBy }] : []) }); + return service as unknown as WarehousePlacementService; +} + +const placementInput = { + slotId: 'slot-2', + warehouseId: 'wh-1', + yardId: 'yard-1', + zoneId: 'zone-1', + quantity: 1, +}; + +describe('WarehousePlacementService.assertStackable', () => { + const service = Object.create(WarehousePlacementService.prototype) as WarehousePlacementService; + + it('always allows the ground level', () => { + expect(() => service.assertStackable({ level: 1, stackCode: 'ZA-001' }, [])).not.toThrow(); + }); + + it('allows level 2 once level 1 is filled', () => { + expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [1])).not.toThrow(); + }); + + it('allows level 3 once levels 1 and 2 are filled', () => { + expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1, 2])).not.toThrow(); + }); + + it('refuses level 2 over an empty ground level', () => { + expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [])).toThrow( + /level 2 cannot be filled while level\(s\) 1 are empty/, + ); + }); + + it('refuses level 3 when level 2 is empty', () => { + expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1])).toThrow( + /level\(s\) 2 are empty/, + ); + }); +}); + +describe('WarehousePlacementService.validateSlotForInventory', () => { + it('accepts a consistent hierarchy with the level below filled', async () => { + const service = makePlacement({}, [1]); + await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({ + stackId: 'stack-1', + level: 2, + }); + }); + + it('refuses a slot belonging to another zone', async () => { + const service = makePlacement({ zoneId: 'other-zone' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses a zone whose yard is not the one given', async () => { + const service = makePlacement({ yardId: 'other-yard' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/does not belong|not the yard given/); + }); + + it('refuses a yard whose warehouse is not the one given', async () => { + const service = makePlacement({ warehouseId: 'other-wh' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/not the warehouse given/); + }); + + it('refuses an inactive stack', async () => { + const service = makePlacement({ stackStatus: 'INACTIVE', stackIsActive: false }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/Stack ZA-001 is not active/); + }); + + it('refuses a blocked slot', async () => { + const service = makePlacement({ slotStatus: 'BLOCKED' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/is BLOCKED/); + }); + + it('accepts a slot reserved for the box now arriving', async () => { + const service = makePlacement({ slotStatus: 'RESERVED' }, [1]); + await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({ level: 2 }); + }); + + it('refuses a slot another container already stands in', async () => { + const service = makePlacement({}, [1], 'other-inventory'); + await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(ConflictException); + }); + + it('refuses a level above the stack height', async () => { + const service = makePlacement({ level: 4, slotId: 'slot-4' }, [1, 2, 3]); + await expect( + service.validateSlotForInventory({ ...placementInput, slotId: 'slot-4' }), + ).rejects.toThrow(/above stack ZA-001's maximum height of 3/); + }); + + it('refuses a row that still covers several containers', async () => { + const service = makePlacement({}, [1]); + await expect(service.validateSlotForInventory({ ...placementInput, quantity: 5 })).rejects.toThrow( + /covers 5 containers/, + ); + }); + + it('skips container stacking rules for a bulk yard', async () => { + // Level 2 over an empty level 1 would be refused in a container yard; + // a bulk yard has no vertical semantics to enforce. + const service = makePlacement({ yardType: 'BULK_YARD' }, []); + await expect(service.validateSlotForInventory({ ...placementInput, quantity: 12 })).resolves.toMatchObject({ + yardType: 'BULK_YARD', + }); + }); +}); + +describe('WarehousePlacementService.getContainerAccessibility', () => { + function makeAccessibility(placed: unknown, blocking: unknown[]) { + const service = Object.create(WarehousePlacementService.prototype) as Record; + const query = jest + .fn() + .mockResolvedValueOnce(placed ? [placed] : []) + .mockResolvedValueOnce(blocking); + service.em = () => ({ query }); + return service as unknown as WarehousePlacementService; + } + + it('reports a ground container buried under two others', async () => { + const service = makeAccessibility( + { inventoryId: 'inv-1', level: 1, stackId: 'stack-1', stackCode: 'ZA-001' }, + [ + { inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' }, + { inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' }, + ], + ); + + await expect(service.getContainerAccessibility('inv-1')).resolves.toEqual({ + accessible: false, + inventoryId: 'inv-1', + stackCode: 'ZA-001', + level: 1, + blockingContainers: [ + { inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' }, + { inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' }, + ], + }); + }); + + it('reports the top container as reachable', async () => { + const service = makeAccessibility({ inventoryId: 'inv-3', level: 3, stackId: 'stack-1', stackCode: 'ZA-001' }, []); + await expect(service.getContainerAccessibility('inv-3')).resolves.toMatchObject({ accessible: true }); + }); + + it('treats an item with no slot as reachable', async () => { + const service = makeAccessibility({ inventoryId: 'inv-9', level: null, stackId: null, stackCode: null }, []); + await expect(service.getContainerAccessibility('inv-9')).resolves.toEqual({ + accessible: true, + inventoryId: 'inv-9', + stackCode: null, + level: null, + blockingContainers: [], + }); + }); +}); + +describe('WarehouseZoneStacksService guards', () => { + function makeStacksService(occupied: number[]) { + const service = Object.create(WarehouseZoneStacksService.prototype) as Record; + service.placement = { occupiedLevels: jest.fn().mockResolvedValue(occupied) }; + service.stacksRepository = { + findById: jest.fn().mockResolvedValue({ id: 'stack-1', code: 'ZA-001', zoneId: 'zone-1', slots: [] }), + }; + service.dataSource = { transaction: jest.fn() }; + return service as unknown as WarehouseZoneStacksService; + } + + it('refuses to delete a stack that still holds containers', async () => { + await expect(makeStacksService([1, 2]).remove('stack-1')).rejects.toThrow( + /still holds 2 container\(s\) at level\(s\) 1, 2/, + ); + }); + + it('deletes an empty stack', async () => { + const service = makeStacksService([]); + await expect(service.remove('stack-1')).resolves.toEqual({ id: 'stack-1', deleted: true }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts index bd3ce5b87..6cd0001e3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts +++ b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts @@ -17,7 +17,7 @@ function makeYardsService(yard: unknown) { return { service: service as unknown as WarehouseYardsService, yardsRepository }; } -function makeZonesService(zone: unknown, heldInventory: number) { +function makeZonesService(zone: unknown, heldInventory: number, configuredStacks = 0) { const zonesRepository = { findById: jest.fn().mockResolvedValue(zone), softDelete: jest.fn().mockResolvedValue(undefined), @@ -28,6 +28,7 @@ function makeZonesService(zone: unknown, heldInventory: number) { const service = Object.create(WarehouseZonesService.prototype) as Record; service.zonesRepository = zonesRepository; service.inventoryRepository = inventoryRepository; + service.dataSource = { query: jest.fn().mockResolvedValue([{ count: configuredStacks }]) }; return { service: service as unknown as WarehouseZonesService, zonesRepository }; } @@ -72,6 +73,13 @@ describe('WarehouseZonesService.remove', () => { expect(zonesRepository.softDelete).not.toHaveBeenCalled(); }); + it('refuses while ground stacks are still configured in it', async () => { + const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0, 20); + + await expect(service.remove('z1')).rejects.toThrow(/still has 20 configured stack\(s\)/); + expect(zonesRepository.softDelete).not.toHaveBeenCalled(); + }); + it('404s on an unknown zone', async () => { const { service } = makeZonesService(null, 0); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts index 1aae7896f..c34c5fd61 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts new file mode 100644 index 000000000..55c5a2d8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts index 08b15d536..1bdf1630a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts new file mode 100644 index 000000000..4006d6140 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index f437188c7..61c810b7f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity' import { Warehouse } from './warehouse.entity'; import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; +import { WarehouseZoneSlot } from './warehouse-zone-slot.entity'; +import { WarehouseZoneStack } from './warehouse-zone-stack.entity'; // Lifecycle. Supersedes the Batch 1 set // (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. @@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record WarehouseZoneStack, { nullable: true }) + @JoinColumn({ name: 'stack_id' }) + stack?: WarehouseZoneStack | null; + + @Column({ name: 'slot_id', type: 'uuid', nullable: true }) + slotId?: string | null; + + @ManyToOne(() => WarehouseZoneSlot, { nullable: true }) + @JoinColumn({ name: 'slot_id' }) + slot?: WarehouseZoneSlot | null; + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) bookingId?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts new file mode 100644 index 000000000..d783ff300 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseZoneStack } from './warehouse-zone-stack.entity'; + +/** + * Stored slot status is *operator intent* only. Occupancy is never written + * here: it is derived from `warehouse_inventory.slot_id` plus the row's + * lifecycle status, so the two can never drift apart and no exit path + * (load / dispatch / deliver) has to remember to free a slot. The computed + * OCCUPIED value is what the API returns — see `SLOT_EFFECTIVE_STATUSES`. + */ +export const WAREHOUSE_ZONE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const; +export type WarehouseZoneSlotStatus = (typeof WAREHOUSE_ZONE_SLOT_STATUSES)[number]; + +export const SLOT_EFFECTIVE_STATUSES = [...WAREHOUSE_ZONE_SLOT_STATUSES, 'OCCUPIED'] as const; +export type SlotEffectiveStatus = (typeof SLOT_EFFECTIVE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_zone_slots' }) +@Index(['stackId']) +@Index(['status']) +export class WarehouseZoneSlot extends BaseEntity { + @Column({ name: 'stack_id', type: 'uuid' }) + stackId!: string; + + @ManyToOne(() => WarehouseZoneStack, (stack) => stack.slots, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'stack_id' }) + stack?: WarehouseZoneStack; + + /** 1 = on the ground. Capped by the parent stack's maxStackHeight. */ + @Column({ name: 'level', type: 'int' }) + level!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'AVAILABLE' }) + status!: WarehouseZoneSlotStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts new file mode 100644 index 000000000..25add0f8e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts @@ -0,0 +1,60 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { WarehouseZone } from './warehouse-zone.entity'; +import { WarehouseZoneSlot } from './warehouse-zone-slot.entity'; + +export const WAREHOUSE_ZONE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseZoneStackStatus = (typeof WAREHOUSE_ZONE_STACK_STATUSES)[number]; + +/** Default vertical height of a container stack — three boxes, EDR's reach-stacker limit. */ +export const DEFAULT_MAX_STACK_HEIGHT = 3; + +/** + * One ground footprint inside a zone: the patch of concrete a container is put + * down on, and the levels above it. The zone is where allocation stops; this is + * where a box physically sits. + * + * Generic on purpose — a bulk or general-cargo zone may divide itself into + * stacks too — but the vertical stacking rules only run for CONTAINER_YARD. + */ +@Entity({ schema: 'freight', name: 'warehouse_zone_stacks' }) +@Index(['zoneId']) +@Index(['status']) +export class WarehouseZoneStack extends BaseEntity { + @Column({ name: 'zone_id', type: 'uuid' }) + zoneId!: string; + + @ManyToOne(() => WarehouseZone, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'zone_id' }) + zone?: WarehouseZone; + + /** Unique within the zone, e.g. ZA-001. */ + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 160, nullable: true }) + name?: string | null; + + /** Free-form physical coordinates. Labels, not numbers — yards mix A/B/C with 1/2/3. */ + @Column({ name: 'row', type: 'varchar', length: 20, nullable: true }) + row?: string | null; + + @Column({ name: 'bay', type: 'varchar', length: 20, nullable: true }) + bay?: string | null; + + @Column({ name: 'position', type: 'varchar', length: 20, nullable: true }) + position?: string | null; + + @Column({ name: 'max_stack_height', type: 'int', default: DEFAULT_MAX_STACK_HEIGHT }) + maxStackHeight!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseZoneStackStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WarehouseZoneSlot, (slot) => slot.stack) + slots?: WarehouseZoneSlot[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index da055a6c6..15a3f6c4a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -14,6 +14,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { AssignSlotDto, FindAvailableSlotDto } from './dto/placement.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { @@ -392,6 +393,50 @@ export class WarehouseInventoryController { return this.inventoryService.move(id, dto); } + @Post('placement/find-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Lowest free stack level for a container yard', + description: 'Read-only preview of where the placement engine would put the next container.', + }) + findAvailableSlot(@Body() dto: FindAvailableSlotDto) { + return this.inventoryService.findAvailableSlot(dto); + } + + @Post(':id/assign-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Place inventory at an exact stack level', + description: 'Omit slotId to take the lowest free level in the item\'s current zone.', + }) + assignSlot( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignSlotDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.assignSlot(id, dto.slotId, actorLabel(user)); + } + + @Post(':id/release-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Take inventory off its stack level', + description: 'Refused while other containers are stacked on top of it.', + }) + releaseSlot(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.inventoryService.releaseSlot(id, actorLabel(user)); + } + + @Get(':id/accessibility') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Can this container be lifted out', + description: 'Lists the containers stacked above it. Nothing is moved.', + }) + accessibility(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.getContainerAccessibility(id); + } + @Post(':id/store') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index efdfa4cd7..a2313a8df 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -74,6 +74,7 @@ import { Warehouse } from './entities/warehouse.entity'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehousePlacementService } from './warehouse-placement.service'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; @@ -416,6 +417,7 @@ export class WarehouseInventoryService { private readonly activityLog: WarehouseActivityLogService, private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, + private readonly placement: WarehousePlacementService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, private readonly releaseDocuments: WarehouseReleaseDocumentService, @@ -3130,12 +3132,30 @@ export class WarehouseInventoryService { if ( item.warehouseId === dto.warehouseId && item.yardId === dto.yardId && - item.zoneId === dto.zoneId + item.zoneId === dto.zoneId && + (item.slotId ?? null) === (dto.slotId ?? null) ) { throw new BadRequestException('Destination location is the same as current location'); } const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + // A move that names a slot is validated against the hierarchy it claims; + // one that does not clears the old slot, because the box has left it. + if (dto.slotId) { + await this.placement.validateSlotForInventory( + { + slotId: dto.slotId, + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + } + const weight = Number(item.weight) || 0; const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; @@ -3145,7 +3165,12 @@ export class WarehouseInventoryService { if (item.yardId !== dto.yardId) { this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount); } - this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + // Skipped when the zone is unchanged: a slot-to-slot reshuffle inside one + // zone adds nothing to it, and a full zone would otherwise refuse to let + // its own containers be restacked. + if (item.zoneId !== dto.zoneId) { + this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + } await this.applyCapacityDelta( manager, @@ -3164,6 +3189,14 @@ export class WarehouseInventoryService { item.warehouseId = dto.warehouseId; item.yardId = dto.yardId; item.zoneId = dto.zoneId; + if (dto.slotId) { + const slot = await this.placement.resolveSlot(dto.slotId, manager); + item.stackId = slot.stackId; + item.slotId = slot.slotId; + } else { + item.stackId = null; + item.slotId = null; + } if (dto.remarks?.trim()) { const existingNotes = item.notes?.trim(); item.notes = existingNotes @@ -3178,12 +3211,171 @@ export class WarehouseInventoryService { return this.findById(movedId); } + // ── Physical slot placement ────────────────────────────────────────────── + + /** + * Pin an inventory item to an exact stack level, or let the placement engine + * pick the lowest free one. Transactional and locked: the row cannot be moved + * out from under the placement between validation and write. + */ + async assignSlot(id: string, slotId?: string, performedBy?: string): Promise { + await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) throw new NotFoundException(`Inventory item ${id} not found`); + + const criteria = await this.getInventoryAllocationCriteria(item); + const chosenSlotId = + slotId ?? + ( + await this.placement.findAvailableContainerSlot( + { yardId: item.yardId, zoneId: item.zoneId, direction: criteria.tradeDirection }, + manager, + ) + )?.slotId; + + if (!chosenSlotId) { + throw new BadRequestException('No free stack level is available in this zone'); + } + + const slot = await this.placement.validateSlotForInventory( + { + slotId: chosenSlotId, + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + + await manager.getRepository(WarehouseInventory).update(id, { + stackId: slot.stackId, + slotId: slot.slotId, + notes: this.appendNote(item.notes, `Placed at ${slot.stackCode} level ${slot.level}`), + }); + + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Placed at ${slot.stackCode} level ${slot.level}`, + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Take the item off its stack level without moving it out of the zone. */ + async releaseSlot(id: string, performedBy?: string): Promise { + await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) throw new NotFoundException(`Inventory item ${id} not found`); + if (!item.slotId) return; + + // Nothing may be standing on top of it — freeing a buried box would leave + // the containers above it floating over an empty level. + await this.placement.assertAccessible(id, manager); + + await manager.getRepository(WarehouseInventory).update(id, { stackId: null, slotId: null }); + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Released from its stack level', + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Whether the box can be lifted out, and what is stacked on top of it if not. */ + getContainerAccessibility(id: string) { + return this.placement.getContainerAccessibility(id); + } + + /** Lowest free stack level for the given yard/zone, without assigning it. */ + findAvailableSlot(input: { + yardId: string; + zoneId?: string; + direction?: string; + cargoTypeId?: string; + }) { + return this.placement.findAvailableContainerSlot(input); + } + + /** + * The physical position an item should take when it is stored. + * + * An explicitly chosen slot is validated and any failure is surfaced — the + * operator asked for that exact level. The automatic path is best-effort: + * a yard with no stacks configured yet, or one that is full, falls back to + * plain zone-level storage rather than blocking a store that worked before + * this model existed. + */ + private async resolveStoragePlacement( + manager: EntityManager, + item: WarehouseInventory, + location: LocationRef, + options: { slotId?: string; direction?: string | null }, + ): Promise<{ stackId: string; slotId: string; label: string } | null> { + const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: location.yardId } }); + if (yard?.type !== 'CONTAINER_YARD') return null; + + if (options.slotId) { + const slot = await this.placement.validateSlotForInventory( + { + slotId: options.slotId, + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + return { stackId: slot.stackId, slotId: slot.slotId, label: `${slot.stackCode} level ${slot.level}` }; + } + + // A row still covering several containers has no single position to take. + if ((Number(item.quantity) || 0) > 1) return null; + + try { + const found = await this.placement.findAvailableContainerSlot( + { yardId: location.yardId, zoneId: location.zoneId, direction: options.direction }, + manager, + ); + return found + ? { stackId: found.stackId, slotId: found.slotId, label: `${found.stackCode} level ${found.level}` } + : null; + } catch (error) { + this.logger.debug( + `Automatic slot placement skipped for inventory ${item.id}: ${(error as Error).message}`, + ); + return null; + } + } + // ── Lifecycle transitions ──────────────────────────────────────────────── async store( id: string, performedBy?: string, - chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string; slotId?: string }, ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); @@ -3247,11 +3439,17 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } - const storedReason = manualLocation + const placed = await this.resolveStoragePlacement(manager, locked, location, { + slotId: chosen?.slotId, + direction: criteria.tradeDirection, + }); + + const baseReason = manualLocation ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` : ruleLocation?.rule ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + const storedReason = placed ? `${baseReason} @ ${placed.label}` : baseReason; await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', @@ -3259,6 +3457,8 @@ export class WarehouseInventoryService { warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, + stackId: placed?.stackId ?? null, + slotId: placed?.slotId ?? null, notes: this.appendNote(locked.notes, storedReason), }); @@ -6163,6 +6363,18 @@ export class WarehouseInventoryService { if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`); const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`); + + // The three ids arrive independently from the client, so they have to be + // checked against each other: a zone belonging to another yard would send + // the item to a location that does not exist on the ground, and every + // capacity counter above it would be adjusted on the wrong row. + if (zone.yardId !== yard.id) { + throw new BadRequestException(`Zone ${zone.code} does not belong to yard ${yard.code}`); + } + if (yard.warehouseId !== warehouse.id) { + throw new BadRequestException(`Yard ${yard.code} does not belong to warehouse ${warehouse.code}`); + } + return { warehouse, yard, zone }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts new file mode 100644 index 000000000..7e70a6e15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts @@ -0,0 +1,644 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; + +import { + SLOT_OCCUPYING_STATUSES, + WarehouseInventory, +} from './entities/warehouse-inventory.entity'; +import { SlotEffectiveStatus } from './entities/warehouse-zone-slot.entity'; + +/** The whole chain above one slot, resolved server-side in a single join. */ +export interface SlotHierarchy { + slotId: string; + slotStatus: string; + slotIsActive: boolean; + level: number; + stackId: string; + stackCode: string; + stackStatus: string; + stackIsActive: boolean; + maxStackHeight: number; + zoneId: string; + zoneCode: string; + zoneType: string; + zoneStatus: string; + zoneIsActive: boolean; + yardId: string; + yardCode: string; + yardType: string; + yardDirection: string | null; + yardStatus: string; + yardIsActive: boolean; + warehouseId: string; + warehouseCode: string; + warehouseStatus: string; + warehouseIsActive: boolean; +} + +export interface AvailableSlot { + slotId: string; + stackId: string; + stackCode: string; + level: number; + zoneId: string; + zoneCode: string; +} + +export interface FindSlotInput { + yardId: string; + zoneId?: string | null; + /** IMPORT | EXPORT — matched against the yard's direction (null = BOTH). */ + direction?: string | null; + cargoTypeId?: string | null; +} + +export interface BlockingContainer { + inventoryId: string; + containerNumber: string | null; + level: number; + status: string; +} + +export interface ContainerAccessibility { + accessible: boolean; + inventoryId: string; + stackCode: string | null; + level: number | null; + blockingContainers: BlockingContainer[]; +} + +export interface SlotSummary { + configuredCapacity: number | null; + physicalSlotCount: number; + occupiedSlotCount: number; + reservedSlotCount: number; + blockedSlotCount: number; + inactiveSlotCount: number; + availableSlotCount: number; + /** True when more physical slots are built than the configured capacity allows. */ + inconsistent: boolean; +} + +export interface ZoneLayoutSlot { + slotId: string; + level: number; + effectiveStatus: SlotEffectiveStatus; + inventoryId: string | null; + containerNumber: string | null; +} + +export interface ZoneLayoutStack { + stackId: string; + code: string; + name: string | null; + maxStackHeight: number; + status: string; + isActive: boolean; + slots: ZoneLayoutSlot[]; +} + +export interface ZoneLayout { + zoneId: string; + zoneCode: string; + zoneName: string; + stacks: ZoneLayoutStack[]; + summary: SlotSummary; +} + +/** + * Container identity has two sources and neither covers the other: a backlog + * registration points `warehouse_inventory.container_id` at a `containers` row, + * while booked cargo carries its numbers on `booking_container_units`. Scalar + * subselects rather than joins, so one slot can never fan out into many rows. + * A booking whose units were never split into one inventory row each shows the + * first unit number — placement refuses such rows anyway (see assertSingleUnit). + */ +const CONTAINER_NUMBER_EXPR = `COALESCE( + (SELECT c.container_number FROM freight.containers c + WHERE c.id = i.container_id AND c.deleted_at IS NULL), + (SELECT bcu.container_number FROM freight.booking_container_units bcu + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = i.booking_id AND bcu.deleted_at IS NULL + ORDER BY bcu.container_number + LIMIT 1) +)`; + +/** A row is "standing in its slot" only in these statuses — same list as the DB's partial unique index. */ +const OCCUPYING = SLOT_OCCUPYING_STATUSES as unknown as string[]; + +/** + * Physical container placement: the stage after allocation. Allocation picks a + * yard (and maybe a zone) from configured rules; this picks the exact stack and + * level, enforces the stacking rules, and answers whether a box can be reached. + * + * Nothing here is called for a non-container yard — bulk, general cargo, + * hazardous and cold storage keep zone-level placement. + */ +@Injectable() +export class WarehousePlacementService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private em(manager?: EntityManager): EntityManager | DataSource { + return manager ?? this.dataSource; + } + + // ── Hierarchy ───────────────────────────────────────────────────────────── + + /** + * Resolve a slot's full chain up to the warehouse. Ids arriving from a client + * are never trusted against one another — this is the one place the chain is + * established, and every caller compares against what comes back here. + */ + async resolveSlot(slotId: string, manager?: EntityManager): Promise { + const [row] = await this.em(manager).query( + `SELECT sl.id AS "slotId", sl.status AS "slotStatus", sl.is_active AS "slotIsActive", + sl.level AS "level", + s.id AS "stackId", s.code AS "stackCode", s.status AS "stackStatus", + s.is_active AS "stackIsActive", s.max_stack_height AS "maxStackHeight", + z.id AS "zoneId", z.code AS "zoneCode", z.type AS "zoneType", + z.status AS "zoneStatus", z.is_active AS "zoneIsActive", + y.id AS "yardId", y.code AS "yardCode", y.type AS "yardType", + y.direction AS "yardDirection", y.status AS "yardStatus", y.is_active AS "yardIsActive", + w.id AS "warehouseId", w.code AS "warehouseCode", + w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive" + FROM freight.warehouse_zone_slots sl + JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL + JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL + JOIN freight.warehouse_yards y ON y.id = z.yard_id AND y.deleted_at IS NULL + JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL + WHERE sl.id = $1 AND sl.deleted_at IS NULL`, + [slotId], + ); + + if (!row) throw new NotFoundException(`Slot ${slotId} not found`); + return row as SlotHierarchy; + } + + /** Levels in a stack currently holding a container, lowest first. */ + async occupiedLevels( + stackId: string, + excludeInventoryId?: string | null, + manager?: EntityManager, + ): Promise { + const rows: Array<{ level: number }> = await this.em(manager).query( + `SELECT sl.level AS "level" + FROM freight.warehouse_zone_slots sl + JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2) + WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL + AND ($3::uuid IS NULL OR i.id <> $3::uuid) + ORDER BY sl.level`, + [stackId, OCCUPYING, excludeInventoryId ?? null], + ); + return rows.map((r) => Number(r.level)); + } + + // ── Placement validation ────────────────────────────────────────────────── + + /** + * Every check that must pass before a container may stand in a slot, in the + * order a yard operator would hit them. Returns the resolved hierarchy so the + * caller writes ids it did not invent. + */ + async validateSlotForInventory( + input: { + slotId: string; + warehouseId: string; + yardId: string; + zoneId: string; + /** Excluded from occupancy checks — the row being moved is allowed to leave its own slot. */ + inventoryId?: string | null; + quantity?: number | null; + }, + manager?: EntityManager, + ): Promise { + const slot = await this.resolveSlot(input.slotId, manager); + + // 1. Hierarchy — the client may not staple a slot onto an unrelated zone/yard/warehouse. + if (slot.zoneId !== input.zoneId) { + throw new BadRequestException( + `Slot ${slot.stackCode}/L${slot.level} belongs to zone ${slot.zoneCode}, not the zone given`, + ); + } + if (slot.yardId !== input.yardId) { + throw new BadRequestException(`Zone ${slot.zoneCode} belongs to yard ${slot.yardCode}, not the yard given`); + } + if (slot.warehouseId !== input.warehouseId) { + throw new BadRequestException( + `Yard ${slot.yardCode} belongs to warehouse ${slot.warehouseCode}, not the warehouse given`, + ); + } + + // 2. Every level of the chain has to be operationally open. + this.assertOperational('Warehouse', slot.warehouseCode, slot.warehouseStatus, slot.warehouseIsActive); + this.assertOperational('Yard', slot.yardCode, slot.yardStatus, slot.yardIsActive); + this.assertOperational('Zone', slot.zoneCode, slot.zoneStatus, slot.zoneIsActive); + this.assertOperational('Stack', slot.stackCode, slot.stackStatus, slot.stackIsActive); + + if (!slot.slotIsActive) { + throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is inactive`); + } + // RESERVED is accepted: a slot is reserved *for* the box now arriving. + if (slot.slotStatus !== 'AVAILABLE' && slot.slotStatus !== 'RESERVED') { + throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is ${slot.slotStatus}`); + } + + // 3. One box per slot. The DB's partial unique index is the backstop; this + // is the readable error the operator actually gets. + const [taken] = await this.em(manager).query( + `SELECT i.id FROM freight.warehouse_inventory i + WHERE i.slot_id = $1 AND i.deleted_at IS NULL AND i.status = ANY($2) + AND ($3::uuid IS NULL OR i.id <> $3::uuid) + LIMIT 1`, + [input.slotId, OCCUPYING, input.inventoryId ?? null], + ); + if (taken) { + throw new ConflictException(`Slot ${slot.stackCode}/L${slot.level} is already occupied`); + } + + if (slot.level > slot.maxStackHeight) { + throw new BadRequestException( + `Level ${slot.level} is above stack ${slot.stackCode}'s maximum height of ${slot.maxStackHeight}`, + ); + } + + // 4. Container yards only: no box may float above an empty level, and a row + // covering several containers has no single physical position. + if (slot.yardType === 'CONTAINER_YARD') { + this.assertSingleUnit(input.quantity); + const occupied = await this.occupiedLevels(slot.stackId, input.inventoryId ?? null, manager); + this.assertStackable(slot, occupied); + } + + return slot; + } + + private assertOperational(label: string, code: string, status: string, isActive: boolean): void { + if (status !== 'ACTIVE' || !isActive) { + throw new BadRequestException(`${label} ${code} is not active`); + } + } + + /** + * A slot is one container. A row that still carries several boxes has no + * single position — split it before placing it, rather than silently pinning + * five containers to one level. + */ + private assertSingleUnit(quantity?: number | null): void { + const qty = Number(quantity ?? 1); + if (qty > 1) { + throw new BadRequestException( + `This inventory row covers ${qty} containers. Split it into one row per container before assigning a slot.`, + ); + } + } + + /** Level N needs every level below it filled — nothing hovers. */ + assertStackable(slot: Pick, occupiedLevels: number[]): void { + if (slot.level === 1) return; + const missing: number[] = []; + for (let level = 1; level < slot.level; level += 1) { + if (!occupiedLevels.includes(level)) missing.push(level); + } + if (missing.length > 0) { + throw new BadRequestException( + `Stack ${slot.stackCode}: level ${slot.level} cannot be filled while level(s) ${missing.join(', ')} are empty`, + ); + } + } + + // ── Finding a slot ──────────────────────────────────────────────────────── + + /** + * Lowest valid free level, deterministic: zone code, then stack code, then + * level. Bottom-up by construction — a stack's candidate level is always one + * above its current top, so level 2 can never be picked before level 1. + * + * Isolated on purpose: a smarter strategy (weight, direction, dwell time) + * swaps in here without touching any caller. + */ + async findAvailableContainerSlot(input: FindSlotInput, manager?: EntityManager): Promise { + const yard = await this.loadYardForPlacement(input, manager); + const zoneIds = await this.candidateZoneIds(yard.id, input.zoneId ?? null, manager); + if (zoneIds.length === 0) return null; + + const [slot] = await this.em(manager).query( + `SELECT sl.id AS "slotId", s.id AS "stackId", s.code AS "stackCode", + sl.level AS "level", z.id AS "zoneId", z.code AS "zoneCode" + FROM freight.warehouse_zone_stacks s + JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL + CROSS JOIN LATERAL ( + SELECT COALESCE(MAX(sl2.level), 0) AS top + FROM freight.warehouse_zone_slots sl2 + JOIN freight.warehouse_inventory i2 + ON i2.slot_id = sl2.id AND i2.deleted_at IS NULL AND i2.status = ANY($2) + WHERE sl2.stack_id = s.id AND sl2.deleted_at IS NULL + ) occ + JOIN freight.warehouse_zone_slots sl + ON sl.stack_id = s.id AND sl.deleted_at IS NULL + AND sl.level = occ.top + 1 + AND sl.status = 'AVAILABLE' AND sl.is_active = true + WHERE s.zone_id = ANY($1::uuid[]) + AND s.deleted_at IS NULL AND s.status = 'ACTIVE' AND s.is_active = true + AND occ.top < s.max_stack_height + ORDER BY z.code, s.code, sl.level + LIMIT 1`, + [zoneIds, OCCUPYING], + ); + + return (slot as AvailableSlot) ?? null; + } + + /** Yard gates: active, a container yard, right direction, right cargo type. */ + private async loadYardForPlacement( + input: FindSlotInput, + manager?: EntityManager, + ): Promise<{ id: string; code: string }> { + const [yard] = await this.em(manager).query( + `SELECT y.id, y.code, y.type, y.direction, y.status, y.is_active AS "isActive", + y.capacity_containers AS "capacityContainers", y.current_containers AS "currentContainers", + w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive", w.code AS "warehouseCode" + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL + WHERE y.id = $1 AND y.deleted_at IS NULL`, + [input.yardId], + ); + if (!yard) throw new NotFoundException(`Yard ${input.yardId} not found`); + + this.assertOperational('Warehouse', yard.warehouseCode, yard.warehouseStatus, yard.warehouseIsActive); + this.assertOperational('Yard', yard.code, yard.status, yard.isActive); + + if (yard.type !== 'CONTAINER_YARD') { + throw new BadRequestException(`Yard ${yard.code} is a ${yard.type}; container stacking does not apply`); + } + + // Null direction has always meant "takes both" — never treat it as invalid. + const yardDirection = yard.direction ?? 'BOTH'; + const wanted = input.direction ?? 'BOTH'; + if (yardDirection !== 'BOTH' && wanted !== 'BOTH' && yardDirection !== wanted) { + throw new BadRequestException(`Yard ${yard.code} serves ${yardDirection} traffic, not ${wanted}`); + } + + // Empty cargo-type relation = open to any cargo. Preserved deliberately. + if (input.cargoTypeId) { + const [{ allowed }] = await this.em(manager).query( + `SELECT (NOT EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t WHERE t.yard_id = $1) + OR EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t + WHERE t.yard_id = $1 AND t.cargo_type_id = $2)) AS allowed`, + [yard.id, input.cargoTypeId], + ); + if (!allowed) { + throw new BadRequestException(`Yard ${yard.code} does not accept this cargo type`); + } + } + + if (yard.capacityContainers != null && Number(yard.currentContainers) >= Number(yard.capacityContainers)) { + throw new BadRequestException( + `Yard ${yard.code} is at its configured capacity (${yard.currentContainers}/${yard.capacityContainers})`, + ); + } + + return { id: yard.id, code: yard.code }; + } + + /** Active container zones in the yard with configured capacity left, in code order. */ + private async candidateZoneIds( + yardId: string, + zoneId: string | null, + manager?: EntityManager, + ): Promise { + const rows: Array<{ id: string }> = await this.em(manager).query( + `SELECT z.id + FROM freight.warehouse_zones z + WHERE z.yard_id = $1 AND z.deleted_at IS NULL + AND z.status = 'ACTIVE' AND z.is_active = true + AND z.type = 'CONTAINER_ZONE' + AND (z.capacity_containers IS NULL OR z.current_containers < z.capacity_containers) + AND ($2::uuid IS NULL OR z.id = $2::uuid) + ORDER BY z.code`, + [yardId, zoneId], + ); + return rows.map((r) => r.id); + } + + // ── Accessibility ───────────────────────────────────────────────────────── + + /** + * Whether a box can be taken out without touching anything else. Containers + * standing above it block it; nothing is moved to clear the way — a + * relocation is an operator decision, not a side effect of a read. + */ + async getContainerAccessibility(inventoryId: string, manager?: EntityManager): Promise { + const [placed] = await this.em(manager).query( + `SELECT i.id AS "inventoryId", sl.level AS "level", s.id AS "stackId", s.code AS "stackCode" + FROM freight.warehouse_inventory i + LEFT JOIN freight.warehouse_zone_slots sl ON sl.id = i.slot_id AND sl.deleted_at IS NULL + LEFT JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL + WHERE i.id = $1 AND i.deleted_at IS NULL`, + [inventoryId], + ); + + if (!placed) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + + // No slot = zone-level placement (bulk, or an item that predates the model): + // nothing is stacked on it, so it is reachable. + if (!placed.stackId) { + return { accessible: true, inventoryId, stackCode: null, level: null, blockingContainers: [] }; + } + + const blocking: BlockingContainer[] = await this.em(manager).query( + `SELECT i.id AS "inventoryId", sl.level AS "level", i.status AS "status", + ${CONTAINER_NUMBER_EXPR} AS "containerNumber" + FROM freight.warehouse_zone_slots sl + JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3) + WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL AND sl.level > $2 + ORDER BY sl.level DESC`, + [placed.stackId, Number(placed.level), OCCUPYING], + ); + + return { + accessible: blocking.length === 0, + inventoryId, + stackCode: placed.stackCode, + level: Number(placed.level), + blockingContainers: blocking.map((b) => ({ ...b, level: Number(b.level) })), + }; + } + + /** Refuse to hand out a box that is buried — used by the exit/delivery paths. */ + async assertAccessible(inventoryId: string, manager?: EntityManager): Promise { + const access = await this.getContainerAccessibility(inventoryId, manager); + if (!access.accessible) { + const above = access.blockingContainers + .map((b) => `${b.containerNumber ?? b.inventoryId} (L${b.level})`) + .join(', '); + throw new ConflictException( + `Container is at ${access.stackCode}/L${access.level} with ${above} stacked above it. Relocate those first.`, + ); + } + } + + // ── Reads ───────────────────────────────────────────────────────────────── + + /** Physical layout of one zone: every stack, every level, what stands there. */ + async zoneLayout(zoneId: string, manager?: EntityManager): Promise { + const [zone] = await this.em(manager).query( + `SELECT z.id, z.code, z.name, z.capacity_containers AS "capacityContainers" + FROM freight.warehouse_zones z WHERE z.id = $1 AND z.deleted_at IS NULL`, + [zoneId], + ); + if (!zone) throw new NotFoundException(`Warehouse zone ${zoneId} not found`); + + const rows: Array<{ + stackId: string; + code: string; + name: string | null; + maxStackHeight: number; + stackStatus: string; + stackIsActive: boolean; + slotId: string | null; + level: number | null; + slotStatus: string | null; + slotIsActive: boolean | null; + inventoryId: string | null; + containerNumber: string | null; + }> = await this.em(manager).query( + `SELECT s.id AS "stackId", s.code AS "code", s.name AS "name", + s.max_stack_height AS "maxStackHeight", s.status AS "stackStatus", + s.is_active AS "stackIsActive", + sl.id AS "slotId", sl.level AS "level", sl.status AS "slotStatus", + sl.is_active AS "slotIsActive", + i.id AS "inventoryId", + CASE WHEN i.id IS NULL THEN NULL ELSE ${CONTAINER_NUMBER_EXPR} END AS "containerNumber" + FROM freight.warehouse_zone_stacks s + LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2) + WHERE s.zone_id = $1 AND s.deleted_at IS NULL + ORDER BY s.code, sl.level DESC`, + [zoneId, OCCUPYING], + ); + + const stacks = new Map(); + for (const row of rows) { + let stack = stacks.get(row.stackId); + if (!stack) { + stack = { + stackId: row.stackId, + code: row.code, + name: row.name, + maxStackHeight: Number(row.maxStackHeight), + status: row.stackStatus, + isActive: row.stackIsActive, + slots: [], + }; + stacks.set(row.stackId, stack); + } + if (row.slotId) { + stack.slots.push({ + slotId: row.slotId, + level: Number(row.level), + effectiveStatus: this.effectiveStatus(row.slotStatus, row.slotIsActive, row.inventoryId), + inventoryId: row.inventoryId, + containerNumber: row.containerNumber, + }); + } + } + + return { + zoneId: zone.id, + zoneCode: zone.code, + zoneName: zone.name, + stacks: [...stacks.values()], + summary: await this.slotSummary({ zoneId }, manager), + }; + } + + private effectiveStatus( + status: string | null, + isActive: boolean | null, + inventoryId: string | null, + ): SlotEffectiveStatus { + if (inventoryId) return 'OCCUPIED'; + if (isActive === false) return 'INACTIVE'; + return (status as SlotEffectiveStatus) ?? 'AVAILABLE'; + } + + /** + * The three numbers that are routinely confused: what was configured, what is + * physically built, and what is actually full. Configured capacity is never + * overwritten from the slot count — a mismatch is reported, not corrected. + */ + async slotSummary( + scope: { zoneId?: string; yardId?: string }, + manager?: EntityManager, + ): Promise { + if (!scope.zoneId && !scope.yardId) { + throw new BadRequestException('A zone or yard is required'); + } + + const [row] = await this.em(manager).query( + `SELECT + (SELECT SUM(z.capacity_containers) + FROM freight.warehouse_zones z + WHERE z.deleted_at IS NULL + AND ($1::uuid IS NULL OR z.id = $1::uuid) + AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)) AS "configuredCapacity", + COUNT(sl.id) AS "physicalSlotCount", + COUNT(i.id) AS "occupiedSlotCount", + COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'RESERVED') AS "reservedSlotCount", + COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'BLOCKED') AS "blockedSlotCount", + COUNT(*) FILTER (WHERE sl.id IS NOT NULL AND (NOT sl.is_active OR sl.status = 'INACTIVE')) + AS "inactiveSlotCount", + COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'AVAILABLE' + AND s.status = 'ACTIVE' AND s.is_active) AS "availableSlotCount" + FROM freight.warehouse_zones z + JOIN freight.warehouse_zone_stacks s ON s.zone_id = z.id AND s.deleted_at IS NULL + LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory i + ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3) + WHERE z.deleted_at IS NULL + AND ($1::uuid IS NULL OR z.id = $1::uuid) + AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)`, + [scope.zoneId ?? null, scope.yardId ?? null, OCCUPYING], + ); + + const configuredCapacity = row?.configuredCapacity == null ? null : Number(row.configuredCapacity); + const physicalSlotCount = Number(row?.physicalSlotCount ?? 0); + + return { + configuredCapacity, + physicalSlotCount, + occupiedSlotCount: Number(row?.occupiedSlotCount ?? 0), + reservedSlotCount: Number(row?.reservedSlotCount ?? 0), + blockedSlotCount: Number(row?.blockedSlotCount ?? 0), + inactiveSlotCount: Number(row?.inactiveSlotCount ?? 0), + availableSlotCount: Number(row?.availableSlotCount ?? 0), + inconsistent: configuredCapacity != null && physicalSlotCount > configuredCapacity, + }; + } + + /** Free a slot explicitly. Exit paths don't need this — status alone frees it. */ + async releaseSlot(inventoryId: string, manager?: EntityManager): Promise { + await this.em(manager).query( + `UPDATE freight.warehouse_inventory + SET stack_id = NULL, slot_id = NULL, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [inventoryId], + ); + } + + /** Write a validated placement onto an inventory row inside the caller's transaction. */ + async applyPlacement( + manager: EntityManager, + inventoryId: string, + placement: { stackId: string; slotId: string } | null, + ): Promise { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + stackId: placement?.stackId ?? null, + slotId: placement?.slotId ?? null, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts new file mode 100644 index 000000000..f24bcfc33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity'; + +@Injectable() +export class WarehouseZoneSlotsRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts new file mode 100644 index 000000000..17ce778d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts @@ -0,0 +1,102 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + CreateWarehouseZoneStackDto, + UpdateWarehouseZoneSlotDto, + UpdateWarehouseZoneStackDto, +} from './dto/warehouse-zone-stack.dto'; +import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service'; + +/** + * Stacks and slots are zone configuration, so they ride the warehouse-zone + * permissions rather than introducing new keys — a new key needs a matching + * `iam.permissions` row in every environment or boot fails. + */ +@ApiTags('warehouse-zone-stacks') +@ApiBearerAuth() +@Controller('warehouse-zone-stacks') +// Class gate lists every key its routes use: Nest runs class AND method guards. +@BookingStaff([ + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseZones.create, + FREIGHT_PERMS.warehouseZones.update, + FREIGHT_PERMS.warehouseZones.delete, +]) +export class WarehouseZoneStacksController { + constructor(private readonly stacksService: WarehouseZoneStacksService) {} + + @Get() + @ApiOperation({ summary: 'List the ground stacks configured in a zone' }) + findByZone(@Query('zoneId', ParseUUIDPipe) zoneId: string) { + return this.stacksService.findByZone(zoneId); + } + + @Post() + @BookingStaff(FREIGHT_PERMS.warehouseZones.create) + @ApiOperation({ + summary: 'Create a ground stack', + description: 'One slot per level is generated automatically, from 1 to maxStackHeight (default 3).', + }) + create(@Body() dto: CreateWarehouseZoneStackDto, @Query('zoneId') zoneIdQuery?: string) { + const zoneId = dto.zoneId ?? zoneIdQuery; + if (!zoneId) { + throw new BadRequestException('zoneId is required'); + } + return this.stacksService.create(zoneId, dto); + } + + // Declared before ':id' so 'slots' is never swallowed as a stack id. + @Patch('slots/:slotId') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) + @ApiOperation({ + summary: 'Block, reserve, or reactivate one slot', + description: 'Occupancy is derived from inventory and cannot be set here.', + }) + updateSlot(@Param('slotId', ParseUUIDPipe) slotId: string, @Body() dto: UpdateWarehouseZoneSlotDto) { + return this.stacksService.updateSlot(slotId, dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one stack with its slots' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.stacksService.findById(id); + } + + @Get(':id/occupancy') + @ApiOperation({ summary: 'Level-by-level occupancy of one stack' }) + occupancy(@Param('id', ParseUUIDPipe) id: string) { + return this.stacksService.slotOccupancy(id); + } + + @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) + @ApiOperation({ + summary: 'Update a stack', + description: 'Raising maxStackHeight adds slots; lowering it trims the empty top levels.', + }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneStackDto) { + return this.stacksService.update(id, dto); + } + + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.delete) + @ApiOperation({ summary: 'Delete a stack', description: 'Refused while containers still stand in it.' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.stacksService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts new file mode 100644 index 000000000..92a798ec1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity'; + +@Injectable() +export class WarehouseZoneStacksRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZoneStack) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts new file mode 100644 index 000000000..b74116c7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts @@ -0,0 +1,245 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In, IsNull } from 'typeorm'; + +import { + CreateWarehouseZoneStackDto, + UpdateWarehouseZoneSlotDto, + UpdateWarehouseZoneStackDto, +} from './dto/warehouse-zone-stack.dto'; +import { + DEFAULT_MAX_STACK_HEIGHT, + WarehouseZoneStack, +} from './entities/warehouse-zone-stack.entity'; +import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity'; +import { WarehousePlacementService } from './warehouse-placement.service'; +import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository'; +import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +/** + * Ground stacks and their vertical slots — the physical layout of a zone. + * + * Slots are never created by hand: a stack of height 3 is three slots, so they + * are generated with the stack and kept in step with its height. That is the + * only way the placement engine can trust `level` to mean what it says. + */ +@Injectable() +export class WarehouseZoneStacksService { + constructor( + private readonly stacksRepository: WarehouseZoneStacksRepository, + private readonly slotsRepository: WarehouseZoneSlotsRepository, + private readonly zonesService: WarehouseZonesService, + private readonly placement: WarehousePlacementService, + @InjectDataSource() private readonly dataSource: DataSource, + ) {} + + findByZone(zoneId: string): Promise { + return this.stacksRepository.findAll({ + where: { zoneId }, + relations: { slots: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const stack = await this.stacksRepository.findById(id, { relations: { slots: true, zone: true } }); + if (!stack) throw new NotFoundException(`Warehouse zone stack ${id} not found`); + stack.slots?.sort((a, b) => a.level - b.level); + return stack; + } + + /** Create the stack and its slots together — a stack with no slots holds nothing. */ + async create(zoneId: string, dto: CreateWarehouseZoneStackDto): Promise { + await this.zonesService.findById(zoneId); + const code = dto.code.trim(); + await this.assertCodeUnique(zoneId, code); + + const maxStackHeight = dto.maxStackHeight ?? DEFAULT_MAX_STACK_HEIGHT; + + const id = await this.dataSource.transaction(async (manager) => { + const stack = await manager.getRepository(WarehouseZoneStack).save( + manager.getRepository(WarehouseZoneStack).create({ + zoneId, + code, + name: dto.name?.trim() ?? null, + row: dto.row?.trim() ?? null, + bay: dto.bay?.trim() ?? null, + position: dto.position?.trim() ?? null, + maxStackHeight, + status: 'ACTIVE', + isActive: true, + }), + ); + + await this.generateSlots(manager, stack.id, 1, maxStackHeight); + return stack.id; + }); + + return this.findById(id); + } + + async update(id: string, dto: UpdateWarehouseZoneStackDto): Promise { + const existing = await this.findById(id); + const code = dto.code?.trim() ?? existing.code; + + if (code !== existing.code) { + await this.assertCodeUnique(existing.zoneId, code, id); + } + + const newHeight = dto.maxStackHeight ?? existing.maxStackHeight; + const status = dto.status ?? existing.status; + + if (status === 'INACTIVE' && existing.status !== 'INACTIVE') { + await this.assertStackEmpty(id, 'deactivated'); + } + + await this.dataSource.transaction(async (manager) => { + if (newHeight > existing.maxStackHeight) { + await this.generateSlots(manager, id, existing.maxStackHeight + 1, newHeight); + } else if (newHeight < existing.maxStackHeight) { + await this.removeSlotsAbove(manager, id, newHeight, existing.code); + } + + await manager.getRepository(WarehouseZoneStack).update(id, { + code, + name: dto.name?.trim() ?? existing.name, + row: dto.row?.trim() ?? existing.row, + bay: dto.bay?.trim() ?? existing.bay, + position: dto.position?.trim() ?? existing.position, + maxStackHeight: newHeight, + status, + isActive: status === 'ACTIVE', + }); + }); + + return this.findById(id); + } + + /** + * Soft-delete a stack. Refused while anything stands in it — the boxes would + * be left pointing at a position every layout query drops. + */ + async remove(id: string): Promise<{ id: string; deleted: true }> { + const existing = await this.findById(id); + await this.assertStackEmpty(id, 'deleted'); + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseZoneSlot).softDelete({ stackId: id }); + await manager.getRepository(WarehouseZoneStack).softDelete(id); + }); + + return { id: existing.id, deleted: true }; + } + + /** + * Set operator intent on one slot. OCCUPIED is not settable — it is derived + * from the inventory sitting there — and a slot holding a box cannot be + * blocked or switched off underneath it. + */ + async updateSlot(slotId: string, dto: UpdateWarehouseZoneSlotDto): Promise { + const slot = await this.slotsRepository.findById(slotId); + if (!slot) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`); + + const status = dto.status ?? slot.status; + const isActive = dto.isActive ?? (dto.status ? dto.status !== 'INACTIVE' : slot.isActive); + const closingOff = status === 'BLOCKED' || status === 'INACTIVE' || isActive === false; + + if (closingOff) { + const [held] = await this.dataSource.query( + `SELECT i.id FROM freight.warehouse_inventory i + WHERE i.slot_id = $1 AND i.deleted_at IS NULL + AND i.status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP') + LIMIT 1`, + [slotId], + ); + if (held) { + throw new ConflictException('Slot still holds a container. Move it out first.'); + } + } + + const updated = await this.slotsRepository.update(slotId, { status, isActive }); + if (!updated) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`); + return updated; + } + + /** Occupancy of one stack, level by level. */ + async slotOccupancy(stackId: string): Promise< + Array<{ slotId: string; level: number; effectiveStatus: string; inventoryId: string | null }> + > { + const stack = await this.findById(stackId); + const layout = await this.placement.zoneLayout(stack.zoneId); + const found = layout.stacks.find((s) => s.stackId === stackId); + return (found?.slots ?? []).map((s) => ({ + slotId: s.slotId, + level: s.level, + effectiveStatus: s.effectiveStatus, + inventoryId: s.inventoryId, + })); + } + + // ── internals ───────────────────────────────────────────────────────────── + + /** Idempotent: a level that already exists (e.g. after a height cut and re-raise) is skipped. */ + private async generateSlots( + manager: EntityManager, + stackId: string, + fromLevel: number, + toLevel: number, + ): Promise { + const repository = manager.getRepository(WarehouseZoneSlot); + const existing = await repository.find({ where: { stackId }, withDeleted: true }); + const byLevel = new Map(existing.map((slot) => [slot.level, slot])); + + for (let level = fromLevel; level <= toLevel; level += 1) { + const found = byLevel.get(level); + if (found?.deletedAt) { + // Bring a previously trimmed level back rather than colliding with the + // (stack_id, level) unique index. + await repository.restore(found.id); + await repository.update(found.id, { status: 'AVAILABLE', isActive: true }); + } else if (!found) { + await repository.save(repository.create({ stackId, level, status: 'AVAILABLE', isActive: true })); + } + } + } + + private async removeSlotsAbove( + manager: EntityManager, + stackId: string, + newHeight: number, + stackCode: string, + ): Promise { + const occupied = await this.placement.occupiedLevels(stackId, null, manager); + const stillUsed = occupied.filter((level) => level > newHeight); + if (stillUsed.length > 0) { + throw new BadRequestException( + `Stack ${stackCode}: level(s) ${stillUsed.join(', ')} still hold containers — cannot lower the height to ${newHeight}`, + ); + } + + const doomed = await manager.getRepository(WarehouseZoneSlot).find({ + where: { stackId, deletedAt: IsNull() }, + }); + const ids = doomed.filter((slot) => slot.level > newHeight).map((slot) => slot.id); + if (ids.length > 0) { + await manager.getRepository(WarehouseZoneSlot).softDelete({ id: In(ids) }); + } + } + + private async assertStackEmpty(stackId: string, action: string): Promise { + const occupied = await this.placement.occupiedLevels(stackId); + if (occupied.length > 0) { + throw new ConflictException( + `Stack still holds ${occupied.length} container(s) at level(s) ${occupied.join(', ')}. Move them out before it can be ${action}.`, + ); + } + } + + private async assertCodeUnique(zoneId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.stacksRepository.findAll({ where: { zoneId, code } }); + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Stack code ${code} already exists in this zone`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 1a35307cd..c88b8d3d6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -51,6 +51,24 @@ export class WarehouseZonesController { return this.zonesService.contents(id); } + @Get(':id/layout') + @ApiOperation({ + summary: 'Physical layout of a zone', + description: 'Every ground stack with its levels, what stands on each, and the slot summary.', + }) + layout(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.layout(id); + } + + @Get(':id/slot-summary') + @ApiOperation({ + summary: 'Configured capacity vs physical slots vs occupancy', + description: 'Flags a zone whose built slots exceed its configured container capacity.', + }) + slotSummary(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.slotSummary(id); + } + @Delete(':id') @BookingStaff(FREIGHT_PERMS.warehouseZones.delete) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index dc02df6cb..78011a369 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -6,6 +6,7 @@ import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { WarehouseZone } from './entities/warehouse-zone.entity'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehousePlacementService } from './warehouse-placement.service'; import { WarehouseYardsService } from './warehouse-yards.service'; import { WarehouseZonesRepository } from './warehouse-zones.repository'; @@ -27,6 +28,7 @@ export class WarehouseZonesService { private readonly zonesRepository: WarehouseZonesRepository, private readonly yardsService: WarehouseYardsService, private readonly inventoryRepository: WarehouseInventoryRepository, + private readonly placement: WarehousePlacementService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -159,10 +161,23 @@ export class WarehouseZonesService { ); } + /** The zone's physical layout: every ground stack, every level, what stands there. */ + async layout(zoneId: string) { + await this.findById(zoneId); + return this.placement.zoneLayout(zoneId); + } + + /** Configured capacity vs slots actually built vs slots actually full. */ + async slotSummary(zoneId: string) { + await this.findById(zoneId); + return this.placement.slotSummary({ zoneId }); + } + /** * Soft-delete a zone. Inventory points at a zone, so a zone still holding * stock is refused — soft-deleting it would leave those rows pointing at a - * location every zone-joining query drops. + * location every zone-joining query drops. Configured stacks block it for the + * same reason: they would survive their parent and never be reachable again. */ async remove(id: string): Promise<{ id: string; deleted: true }> { const existing = await this.findById(id); @@ -174,6 +189,17 @@ export class WarehouseZonesService { ); } + const [stacks] = await this.dataSource.query( + `SELECT count(*)::int AS count FROM freight.warehouse_zone_stacks + WHERE zone_id = $1 AND deleted_at IS NULL`, + [id], + ); + if (Number(stacks?.count ?? 0) > 0) { + throw new ConflictException( + `Zone ${existing.code} still has ${stacks.count} configured stack(s). Delete them first.`, + ); + } + await this.zonesRepository.softDelete(id); return { id, deleted: true }; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d91ac2baf..48d787e55 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -21,6 +21,8 @@ import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movem import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity'; +import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity'; import { Warehouse } from './entities/warehouse.entity'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; @@ -47,6 +49,11 @@ import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapte import { WarehouseYardsController } from './warehouse-yards.controller'; import { WarehouseYardsRepository } from './warehouse-yards.repository'; import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehousePlacementService } from './warehouse-placement.service'; +import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository'; +import { WarehouseZoneStacksController } from './warehouse-zone-stacks.controller'; +import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository'; +import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service'; import { WarehouseZonesController } from './warehouse-zones.controller'; import { WarehouseZonesRepository } from './warehouse-zones.repository'; import { WarehouseZonesService } from './warehouse-zones.service'; @@ -60,6 +67,8 @@ import { WarehousesService } from './warehouses.service'; Warehouse, WarehouseYard, WarehouseZone, + WarehouseZoneStack, + WarehouseZoneSlot, WarehouseInventory, WarehouseInventoryMovement, WarehouseActivityLog, @@ -83,6 +92,7 @@ import { WarehousesService } from './warehouses.service'; WarehousesController, WarehouseYardsController, WarehouseZonesController, + WarehouseZoneStacksController, WarehouseInventoryController, WarehouseLoadingsController, WarehouseInspectionController, @@ -93,6 +103,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesRepository, WarehouseYardsRepository, WarehouseZonesRepository, + WarehouseZoneStacksRepository, + WarehouseZoneSlotsRepository, WarehouseInventoryRepository, WarehouseInventoryMovementRepository, WarehouseActivityLogRepository, @@ -103,6 +115,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesService, WarehouseYardsService, WarehouseZonesService, + WarehouseZoneStacksService, + WarehousePlacementService, WarehouseInventoryService, WarehouseActivityLogService, WarehouseDashboardService, @@ -119,6 +133,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesService, WarehouseYardsService, WarehouseZonesService, + WarehouseZoneStacksService, + WarehousePlacementService, WarehouseInventoryService, WarehouseAllocationService, WarehouseFeeService, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts new file mode 100644 index 000000000..d0872a6bb --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts @@ -0,0 +1,35 @@ +import { AppDataSource } from '../data-source'; +import { WarehouseLayoutSeeder } from '../seed/warehouse-layout.seeder'; + +/** + * Lays out the physical warehouse structure described by + * `src/seed/warehouse-layout.json` — edit that file, not this script. + * + * Idempotent: existing warehouses, yards, zones, stacks and slots are left + * untouched, so a re-run only fills in what is missing. + */ +async function seedWarehouseLayout() { + await AppDataSource.initialize(); + + try { + const summary = await new WarehouseLayoutSeeder(AppDataSource).run(); + console.table([summary]); + + const counts = await AppDataSource.query(` + SELECT + (SELECT COUNT(*)::int FROM freight.warehouses WHERE deleted_at IS NULL) AS warehouses, + (SELECT COUNT(*)::int FROM freight.warehouse_yards WHERE deleted_at IS NULL) AS yards, + (SELECT COUNT(*)::int FROM freight.warehouse_zones WHERE deleted_at IS NULL) AS zones, + (SELECT COUNT(*)::int FROM freight.warehouse_zone_stacks WHERE deleted_at IS NULL) AS stacks, + (SELECT COUNT(*)::int FROM freight.warehouse_zone_slots WHERE deleted_at IS NULL) AS slots + `); + console.table(counts); + } finally { + await AppDataSource.destroy(); + } +} + +seedWarehouseLayout().catch((error) => { + console.error('Failed to seed the warehouse layout:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/warehouse-layout.json b/apps/edr-freight-api/src/seed/warehouse-layout.json new file mode 100644 index 000000000..5563bf112 --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-layout.json @@ -0,0 +1,28 @@ +{ + "facility": { + "code": "GELAN", + "name": "Gelan Dry Port", + "facilityType": "DRY_PORT" + }, + "levels": ["L1", "L2", "L3", "L4"], + "kinds": [ + { "suffix": "OPEN", "letter": "O", "name": "Open Warehouse", "type": "OPEN_WAREHOUSE" }, + { "suffix": "CLOSED", "letter": "C", "name": "Closed Warehouse", "type": "CLOSED_WAREHOUSE" } + ], + "yards": [ + { "label": "A", "type": "CONTAINER_YARD", "capacityContainers": 150 }, + { "label": "B", "type": "CONTAINER_YARD", "capacityContainers": 150 }, + { "label": "C", "type": "GENERAL_CARGO_YARD", "capacityContainers": null }, + { "label": "D", "type": "BULK_YARD", "capacityContainers": null }, + { "label": "E", "type": "HAZARDOUS_YARD", "capacityContainers": null }, + { "label": "F", "type": "COLD_STORAGE_YARD", "capacityContainers": null } + ], + "zones": [ + { "label": "A", "capacityContainers": 60 }, + { "label": "B", "capacityContainers": 45 }, + { "label": "C", "capacityContainers": 45 } + ], + "stack": { + "maxStackHeight": 3 + } +} diff --git a/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts new file mode 100644 index 000000000..e7533592e --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts @@ -0,0 +1,214 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { DataSource } from 'typeorm'; + +/** + * Builds the physical warehouse layout from `warehouse-layout.json`: + * facility → warehouses (L1-OPEN …) → yards (A–F) → zones (A–C) → ground + * stacks → slots. + * + * The shape is configuration, never enums: yard letters, zone letters and + * stack heights all come from the JSON, because a physical layout changes and + * a deployed enum does not. + * + * Idempotent on every code. A row that already exists is left exactly as it + * is — capacities tuned by hand on a live site must survive a re-run. + */ +export interface WarehouseLayoutConfig { + facility: { code: string; name: string; facilityType: string }; + levels: string[]; + kinds: Array<{ suffix: string; letter: string; name: string; type: string }>; + yards: Array<{ label: string; type: string; capacityContainers: number | null }>; + zones: Array<{ label: string; capacityContainers: number }>; + stack: { maxStackHeight: number }; +} + +export interface LayoutSeedSummary { + facilityCode: string; + warehousesCreated: number; + yardsCreated: number; + zonesCreated: number; + stacksCreated: number; + slotsCreated: number; +} + +export class WarehouseLayoutSeeder { + constructor( + private readonly dataSource: DataSource, + private readonly config: WarehouseLayoutConfig = WarehouseLayoutSeeder.loadConfig(), + ) {} + + static loadConfig(path = join(__dirname, 'warehouse-layout.json')): WarehouseLayoutConfig { + return JSON.parse(readFileSync(path, 'utf8')) as WarehouseLayoutConfig; + } + + async run(): Promise { + const summary: LayoutSeedSummary = { + facilityCode: this.config.facility.code, + warehousesCreated: 0, + yardsCreated: 0, + zonesCreated: 0, + stacksCreated: 0, + slotsCreated: 0, + }; + + const facilityId = await this.upsertFacility(); + + for (const level of this.config.levels) { + for (const kind of this.config.kinds) { + const warehouseCode = `${level}-${kind.suffix}`; + const warehouse = await this.upsertWarehouse(facilityId, warehouseCode, `${level} ${kind.name}`, kind.type); + summary.warehousesCreated += warehouse.created ? 1 : 0; + + for (const yardCfg of this.config.yards) { + const yardCode = `${level}-${kind.letter}-${yardCfg.label}`; + const yard = await this.upsertYard(warehouse.id, yardCode, `Yard ${yardCfg.label}`, yardCfg); + summary.yardsCreated += yard.created ? 1 : 0; + + // Zones, stacks and slots are only laid out for container yards — + // bulk and general cargo do not stand in numbered positions. + if (yardCfg.type !== 'CONTAINER_YARD') continue; + + for (const zoneCfg of this.config.zones) { + const zoneCode = `${yardCode}-Z${zoneCfg.label}`; + const zone = await this.upsertZone(yard.id, zoneCode, `Zone ${zoneCfg.label}`, zoneCfg.capacityContainers); + summary.zonesCreated += zone.created ? 1 : 0; + + const height = this.config.stack.maxStackHeight; + // Ground positions, not boxes: a 60-container zone stacked three + // high needs 20 patches of concrete. + const stackCount = Math.floor(zoneCfg.capacityContainers / height); + + for (let n = 1; n <= stackCount; n += 1) { + const stackCode = `Z${zoneCfg.label}-${String(n).padStart(3, '0')}`; + const stack = await this.upsertStack(zone.id, stackCode, height); + summary.stacksCreated += stack.created ? 1 : 0; + summary.slotsCreated += await this.upsertSlots(stack.id, height); + } + } + } + } + } + + return summary; + } + + private async upsertFacility(): Promise { + const { code, name, facilityType } = this.config.facility; + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.facilities WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (existing) return existing.id; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.facilities (code, name, facility_type, facility_status, is_active) + VALUES ($1, $2, $3, 'ACTIVE', true) + RETURNING id`, + [code, name, facilityType], + ); + return created.id; + } + + private async upsertWarehouse( + facilityId: string, + code: string, + name: string, + type: string, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouses WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouses (code, name, type, facility_id, status, is_active, + current_weight, current_containers, current_volume) + VALUES ($1, $2, $3, $4, 'ACTIVE', true, 0, 0, 0) + RETURNING id`, + [code, name, type, facilityId], + ); + return { id: created.id, created: true }; + } + + private async upsertYard( + warehouseId: string, + code: string, + name: string, + cfg: { type: string; capacityContainers: number | null }, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouse_yards + WHERE warehouse_id = $1 AND code = $2 AND deleted_at IS NULL`, + [warehouseId, code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouse_yards (warehouse_id, code, name, type, capacity_containers, + status, is_active, current_weight, current_containers, current_volume) + VALUES ($1, $2, $3, $4, $5, 'ACTIVE', true, 0, 0, 0) + RETURNING id`, + [warehouseId, code, name, cfg.type, cfg.capacityContainers], + ); + return { id: created.id, created: true }; + } + + private async upsertZone( + yardId: string, + code: string, + name: string, + capacityContainers: number, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouse_zones WHERE yard_id = $1 AND code = $2 AND deleted_at IS NULL`, + [yardId, code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouse_zones (yard_id, code, name, type, capacity_containers, + status, is_active, current_weight, current_containers, current_volume) + VALUES ($1, $2, $3, 'CONTAINER_ZONE', $4, 'ACTIVE', true, 0, 0, 0) + RETURNING id`, + [yardId, code, name, capacityContainers], + ); + return { id: created.id, created: true }; + } + + private async upsertStack( + zoneId: string, + code: string, + maxStackHeight: number, + ): Promise<{ id: string; created: boolean }> { + const [existing] = await this.dataSource.query( + `SELECT id FROM freight.warehouse_zone_stacks WHERE zone_id = $1 AND code = $2 AND deleted_at IS NULL`, + [zoneId, code], + ); + if (existing) return { id: existing.id, created: false }; + + const [created] = await this.dataSource.query( + `INSERT INTO freight.warehouse_zone_stacks (zone_id, code, max_stack_height, status, is_active) + VALUES ($1, $2, $3, 'ACTIVE', true) + RETURNING id`, + [zoneId, code, maxStackHeight], + ); + return { id: created.id, created: true }; + } + + private async upsertSlots(stackId: string, height: number): Promise { + const result = await this.dataSource.query( + `INSERT INTO freight.warehouse_zone_slots (stack_id, level, status, is_active) + SELECT $1, lvl, 'AVAILABLE', true + FROM generate_series(1, $2) AS lvl + WHERE NOT EXISTS ( + SELECT 1 FROM freight.warehouse_zone_slots s + WHERE s.stack_id = $1 AND s.level = lvl AND s.deleted_at IS NULL + ) + RETURNING id`, + [stackId, height], + ); + return Array.isArray(result) ? result.length : 0; + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx index 24c618d3e..2c0672078 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneContentsModal.tsx @@ -4,12 +4,19 @@ import { DataTable, type ColumnDef } from '@edr/ui-common'; import { formatDateTime, humanize } from '@/lib/format'; import { api } from '@/services/api'; -import type { WarehouseZone, ZoneContentItem } from '@/types/warehouse'; +import type { ZoneContentItem } from '@/types/warehouse'; + +/** Enough to name the zone and fetch it — satisfied by WarehouseZone and ZoneOccupancy alike. */ +export interface ZoneRef { + id: string; + name: string; + code: string; +} interface ZoneContentsModalProps { opened: boolean; onClose: () => void; - zone: WarehouseZone | null; + zone: ZoneRef | null; } const columns: ColumnDef[] = [ diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx index 9fcbd88ed..6ceb3e11d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ZoneOccupancyHeatmap.tsx @@ -25,13 +25,15 @@ function capacityLabel(z: ZoneOccupancy): string { interface ZoneOccupancyHeatmapProps { /** Scope to one yard; omit for all zones. */ yardId?: string; + /** Pass to make each tile open that zone; omitted leaves the tiles inert. */ + onZoneClick?: (zone: ZoneOccupancy) => void; } /** * Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is * container-count based (unit-consistent); weight is shown as context only. */ -export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) { +export function ZoneOccupancyHeatmap({ yardId, onZoneClick }: ZoneOccupancyHeatmapProps) { const { data: zones = [], isLoading } = useZoneOccupancy(yardId); if (isLoading) { @@ -67,7 +69,15 @@ export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) { const t = tone(z.occupancyPct); const pct = z.occupancyPct ?? 0; return ( - + onZoneClick(z) : undefined} + style={onZoneClick ? { cursor: 'pointer' } : undefined} + title={onZoneClick ? `View what is stored in ${z.name}` : undefined} + > diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index ab7591f61..24d727854 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -9,7 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable'; export { CreateWarehouseModal } from './CreateWarehouseModal'; export { CreateYardModal } from './CreateYardModal'; export { CreateZoneModal } from './CreateZoneModal'; -export { ZoneContentsModal } from './ZoneContentsModal'; +export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal'; export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal'; export { WarehouseInfoCard } from './WarehouseInfoCard'; export { MoveInventoryModal } from './MoveInventoryModal'; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index 0cd281ad3..fa4b786de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -24,6 +24,7 @@ import { WarehouseStatusBadge, WarehouseTypeBadge, ZoneContentsModal, + type ZoneRef, ZoneOccupancyHeatmap, formatCapacity, humanizeEnum, @@ -61,7 +62,7 @@ export default function WarehouseDetailPage() { const [zoneModalOpen, setZoneModalOpen] = useState(false); const [editingZone, setEditingZone] = useState(null); const [selectedYardId, setSelectedYardId] = useState(null); - const [contentsZone, setContentsZone] = useState(null); + const [contentsZone, setContentsZone] = useState(null); const zonesQuery = useQuery( api.warehouses.listZones.queryOptions({ @@ -373,7 +374,10 @@ export default function WarehouseDetailPage() { - + setContentsZone(zone)} + /> {!selectedYardId ? (