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