Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.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

223 lines
8.7 KiB
TypeScript

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