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

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