mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 10:03:26 +00:00
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
223 lines
8.7 KiB
TypeScript
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 });
|
|
});
|
|
});
|