Files
edr-platform/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts
Hagernesh a6ea1a48ac 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
2026-08-28 16:09:41 +00:00

129 lines
5.8 KiB
TypeScript

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