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

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

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

View File

@@ -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",

View File

@@ -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<void> {
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<void> {
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`);
}
}

View File

@@ -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<typeof CHAIN>, occupiedLevels: number[], slotTakenBy: string | null = null) {
const service = Object.create(WarehousePlacementService.prototype) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 });
});
});

View File

@@ -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<string, unknown>;
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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<WarehouseInventoryStatus, W
DELIVERED: [],
};
/**
* Statuses in which an inventory row is still physically standing in its slot.
* The moment it is LOADED onto a train, dispatched, or handed over, the ground
* is free again — so occupancy is read from this list rather than written to
* the slot row. The partial unique index in
* `WarehouseZoneStacksSlots3830000000000` uses exactly the same list; change
* one and you must change the other.
*/
export const SLOT_OCCUPYING_STATUSES: readonly WarehouseInventoryStatus[] = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'READY_FOR_PICKUP',
];
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@Index(['warehouseId'])
@Index(['yardId'])
@@ -58,6 +77,8 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
@Index(['cargoId'])
@Index(['containerId'])
@Index(['goodsId'])
@Index(['stackId'])
@Index(['slotId'])
@Index(['status'])
export class WarehouseInventory extends BaseEntity {
@Column({ name: 'warehouse_id', type: 'uuid' })
@@ -81,6 +102,25 @@ export class WarehouseInventory extends BaseEntity {
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
/**
* Exact physical position inside the zone. Nullable and additive: every row
* that predates the stack/slot model, and every non-container yard, keeps
* working with zone-level placement alone.
*/
@Column({ name: 'stack_id', type: 'uuid', nullable: true })
stackId?: string | null;
@ManyToOne(() => 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;

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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)' })

View File

@@ -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<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseInventory> {
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 };
}

View File

@@ -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<SlotHierarchy> {
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<number[]> {
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<SlotHierarchy> {
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<SlotHierarchy, 'level' | 'stackCode'>, 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<AvailableSlot | null> {
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<string[]> {
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<ContainerAccessibility> {
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<void> {
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<ZoneLayout> {
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<string, ZoneLayoutStack>();
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<SlotSummary> {
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<void> {
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<void> {
await manager.getRepository(WarehouseInventory).update(inventoryId, {
stackId: placement?.stackId ?? null,
slotId: placement?.slotId ?? null,
});
}
}

View File

@@ -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<WarehouseZoneSlot> {
constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository<WarehouseZoneSlot>) {
super(repository);
}
}

View File

@@ -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);
}
}

View File

@@ -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<WarehouseZoneStack> {
constructor(@InjectRepository(WarehouseZoneStack) repository: Repository<WarehouseZoneStack>) {
super(repository);
}
}

View File

@@ -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<WarehouseZoneStack[]> {
return this.stacksRepository.findAll({
where: { zoneId },
relations: { slots: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<WarehouseZoneStack> {
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<WarehouseZoneStack> {
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<WarehouseZoneStack> {
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<WarehouseZoneSlot> {
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<void> {
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<void> {
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<void> {
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<void> {
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`);
}
}
}

View File

@@ -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({

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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
}
}

View File

@@ -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 (AF) → zones (AC) → 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<LayoutSeedSummary> {
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<string> {
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<number> {
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;
}
}

View File

@@ -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<ZoneContentItem>[] = [

View File

@@ -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 (
<Card key={z.id} withBorder radius="md" padding="sm">
<Card
key={z.id}
withBorder
radius="md"
padding="sm"
onClick={onZoneClick ? () => onZoneClick(z) : undefined}
style={onZoneClick ? { cursor: 'pointer' } : undefined}
title={onZoneClick ? `View what is stored in ${z.name}` : undefined}
>
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text fw={600} size="sm" truncate title={z.name}>

View File

@@ -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';

View File

@@ -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<WarehouseZone | null>(null);
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
const [contentsZone, setContentsZone] = useState<WarehouseZone | null>(null);
const [contentsZone, setContentsZone] = useState<ZoneRef | null>(null);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
@@ -373,7 +374,10 @@ export default function WarehouseDetailPage() {
</Button>
</Group>
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
<ZoneOccupancyHeatmap
yardId={selectedYardId ?? undefined}
onZoneClick={(zone) => setContentsZone(zone)}
/>
{!selectedYardId ? (
<Text c="dimmed" ta="center" py="lg">