mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
merge conflict resolved
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
/**
|
||||
* Deleting a warehouse that still holds yards would orphan every zone and the
|
||||
* inventory sitting in them, so remove() refuses instead of cascading.
|
||||
*/
|
||||
function makeService(warehouse: unknown) {
|
||||
const warehousesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(warehouse),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = Object.create(WarehousesService.prototype) as Record<string, unknown>;
|
||||
service.warehousesRepository = warehousesRepository;
|
||||
|
||||
return { service: service as unknown as WarehousesService, warehousesRepository };
|
||||
}
|
||||
|
||||
describe('WarehousesService.remove', () => {
|
||||
it('soft-deletes a warehouse with no yards', async () => {
|
||||
const { service, warehousesRepository } = makeService({ id: 'w1', code: 'GMP', yards: [] });
|
||||
|
||||
await expect(service.remove('w1')).resolves.toEqual({ id: 'w1', deleted: true });
|
||||
expect(warehousesRepository.softDelete).toHaveBeenCalledWith('w1');
|
||||
});
|
||||
|
||||
it('refuses while yards remain', async () => {
|
||||
const { service, warehousesRepository } = makeService({
|
||||
id: 'w1',
|
||||
code: 'GMP',
|
||||
yards: [{ id: 'y1' }],
|
||||
});
|
||||
|
||||
await expect(service.remove('w1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s on an unknown warehouse', async () => {
|
||||
const { service, warehousesRepository } = makeService(null);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
/**
|
||||
* Soft-deleting a parent would leave its children pointing at a row every
|
||||
* joining query drops, so both removes refuse while children exist.
|
||||
*/
|
||||
function makeYardsService(yard: unknown) {
|
||||
const yardsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(yard),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = Object.create(WarehouseYardsService.prototype) as Record<string, unknown>;
|
||||
service.yardsRepository = yardsRepository;
|
||||
return { service: service as unknown as WarehouseYardsService, yardsRepository };
|
||||
}
|
||||
|
||||
function makeZonesService(zone: unknown, heldInventory: number, configuredStacks = 0) {
|
||||
const zonesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(zone),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const inventoryRepository = {
|
||||
findAndCount: jest.fn().mockResolvedValue([[], heldInventory]),
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('WarehouseYardsService.remove', () => {
|
||||
it('soft-deletes a yard with no zones', async () => {
|
||||
const { service, yardsRepository } = makeYardsService({ id: 'y1', code: 'CY-A', zones: [] });
|
||||
|
||||
await expect(service.remove('y1')).resolves.toEqual({ id: 'y1', deleted: true });
|
||||
expect(yardsRepository.softDelete).toHaveBeenCalledWith('y1');
|
||||
});
|
||||
|
||||
it('refuses while zones remain', async () => {
|
||||
const { service, yardsRepository } = makeYardsService({
|
||||
id: 'y1',
|
||||
code: 'CY-A',
|
||||
zones: [{ id: 'z1' }],
|
||||
});
|
||||
|
||||
await expect(service.remove('y1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(yardsRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s on an unknown yard', async () => {
|
||||
const { service } = makeYardsService(null);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarehouseZonesService.remove', () => {
|
||||
it('soft-deletes an empty zone', async () => {
|
||||
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0);
|
||||
|
||||
await expect(service.remove('z1')).resolves.toEqual({ id: 'z1', deleted: true });
|
||||
expect(zonesRepository.softDelete).toHaveBeenCalledWith('z1');
|
||||
});
|
||||
|
||||
it('refuses while inventory sits in it', async () => {
|
||||
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 16);
|
||||
|
||||
await expect(service.remove('z1')).rejects.toBeInstanceOf(ConflictException);
|
||||
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);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@@ -187,6 +200,22 @@ export class BulkReceiveDto {
|
||||
@IsUUID('all', { each: true })
|
||||
bookingIds!: string[];
|
||||
|
||||
/**
|
||||
* The physical containers delivered by this truck. Container exports are
|
||||
* received one truck at a time: either one 40ft box or up to two 20ft boxes.
|
||||
*/
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumbers?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: TruckEntranceDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
import {
|
||||
FREIGHT_TYPES,
|
||||
FreightType,
|
||||
WAREHOUSE_STATUSES,
|
||||
WAREHOUSE_TYPES,
|
||||
WarehouseStatus,
|
||||
WarehouseType,
|
||||
} from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@@ -19,6 +26,11 @@ export class CreateWarehouseDto {
|
||||
@IsEnum(WAREHOUSE_TYPES)
|
||||
type!: WarehouseType;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES, description: 'Omit for a warehouse that takes both.' })
|
||||
@IsOptional()
|
||||
@IsEnum(FREIGHT_TYPES)
|
||||
freightType?: FreightType;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -14,6 +14,14 @@ export class MoveInventoryDto {
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Exact physical slot in the destination zone. Container yards only.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
slotId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class FindAvailableSlotDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Narrow the search to one zone.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'BOTH'], description: 'Null/BOTH matches any yard direction.' })
|
||||
@IsOptional()
|
||||
@IsIn(['IMPORT', 'EXPORT', 'BOTH'])
|
||||
direction?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
}
|
||||
|
||||
export class AssignSlotDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Target slot. Omit to let the placement engine pick the lowest free level.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
slotId?: string;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A loaded container that was already sitting in a yard before the system knew
|
||||
* about it. It has no booking, so the owner is carried as a company reference
|
||||
* or free text, and `arrivedAt` is the true historical arrival rather than now.
|
||||
*/
|
||||
export class RegisterBacklogContainerDto {
|
||||
@ApiProperty({ description: 'ISO 6346 container number' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ description: 'True historical arrival date — drives nothing billable.' })
|
||||
@IsDateString()
|
||||
arrivedAt!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Registered customer, when the owner is one.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Owner name — free text when the company is not a customer yet.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Net weight in the unit the warehouse records (tonnes).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
/**
|
||||
* ponytail: defaults to 0 when unknown, which is the honest value for a box
|
||||
* nobody weighed. `containers.max_gross_weight` is a ceiling in
|
||||
* cargoes.service, so set real figures here before this box is ever used for
|
||||
* a new cargo assignment.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxGrossWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class BulkRegisterBacklogDto {
|
||||
@ApiProperty({ type: [RegisterBacklogContainerDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RegisterBacklogContainerDto)
|
||||
containers!: RegisterBacklogContainerDto[];
|
||||
}
|
||||
@@ -22,6 +22,15 @@ export class StoreInventoryDto {
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Exact physical slot. Container yards only; omit to let the placement engine pick the lowest free level.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
slotId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_STACK_HEIGHT,
|
||||
WAREHOUSE_ZONE_STACK_STATUSES,
|
||||
WarehouseZoneStackStatus,
|
||||
} from '../entities/warehouse-zone-stack.entity';
|
||||
import {
|
||||
WAREHOUSE_ZONE_SLOT_STATUSES,
|
||||
WarehouseZoneSlotStatus,
|
||||
} from '../entities/warehouse-zone-slot.entity';
|
||||
|
||||
/** Nobody stacks boxes this high; the cap is here to catch a typo'd 30. */
|
||||
const MAX_SUPPORTED_STACK_HEIGHT = 10;
|
||||
|
||||
export class CreateWarehouseZoneStackDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiProperty({ example: 'ZA-001' })
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Physical row label' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
row?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Physical bay label' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bay?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Physical position label' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
position?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: DEFAULT_MAX_STACK_HEIGHT,
|
||||
description: 'One slot is generated per level, 1 to this height.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SUPPORTED_STACK_HEIGHT)
|
||||
maxStackHeight?: number;
|
||||
}
|
||||
|
||||
export class UpdateWarehouseZoneStackDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
row?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bay?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
position?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Raising it adds slots; lowering it removes the empty top levels.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SUPPORTED_STACK_HEIGHT)
|
||||
maxStackHeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STACK_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_ZONE_STACK_STATUSES)
|
||||
status?: WarehouseZoneStackStatus;
|
||||
}
|
||||
|
||||
export class UpdateWarehouseZoneSlotDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: WAREHOUSE_ZONE_SLOT_STATUSES,
|
||||
description: 'Operator intent only. OCCUPIED is derived from inventory and cannot be set here.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_ZONE_SLOT_STATUSES)
|
||||
status?: WarehouseZoneSlotStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity'
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
|
||||
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
|
||||
|
||||
// Lifecycle. Supersedes the Batch 1 set
|
||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||
@@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
DELIVERED: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Statuses in which an inventory row is still physically standing in its slot.
|
||||
* The moment it is LOADED onto a train, dispatched, or handed over, the ground
|
||||
* is free again — so occupancy is read from this list rather than written to
|
||||
* the slot row. The partial unique index in
|
||||
* `WarehouseZoneStacksSlots3830000000000` uses exactly the same list; change
|
||||
* one and you must change the other.
|
||||
*/
|
||||
export const SLOT_OCCUPYING_STATUSES: readonly WarehouseInventoryStatus[] = [
|
||||
'UNLOADED',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'READY_FOR_PICKUP',
|
||||
];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['yardId'])
|
||||
@@ -58,6 +77,8 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
@Index(['cargoId'])
|
||||
@Index(['containerId'])
|
||||
@Index(['goodsId'])
|
||||
@Index(['stackId'])
|
||||
@Index(['slotId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
@@ -81,6 +102,25 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
/**
|
||||
* Exact physical position inside the zone. Nullable and additive: every row
|
||||
* that predates the stack/slot model, and every non-container yard, keeps
|
||||
* working with zone-level placement alone.
|
||||
*/
|
||||
@Column({ name: 'stack_id', type: 'uuid', nullable: true })
|
||||
stackId?: string | null;
|
||||
|
||||
@ManyToOne(() => WarehouseZoneStack, { nullable: true })
|
||||
@JoinColumn({ name: 'stack_id' })
|
||||
stack?: WarehouseZoneStack | null;
|
||||
|
||||
@Column({ name: 'slot_id', type: 'uuid', nullable: true })
|
||||
slotId?: string | null;
|
||||
|
||||
@ManyToOne(() => WarehouseZoneSlot, { nullable: true })
|
||||
@JoinColumn({ name: 'slot_id' })
|
||||
slot?: WarehouseZoneSlot | null;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@@ -105,6 +145,22 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||
goodsId?: string | null;
|
||||
|
||||
/**
|
||||
* Registered as backlog: the box was already in the yard before the system
|
||||
* knew about it. `arrivedAt` is the true, backdated arrival, but no storage
|
||||
* or demurrage accrues — see WarehouseFeeService.previewForInventory.
|
||||
*/
|
||||
@Column({ name: 'backlog_registration', type: 'boolean', default: false })
|
||||
backlogRegistration!: boolean;
|
||||
|
||||
/** Owner of a row with no booking to inherit one from. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
/** Owner as text — a company that is not a registered customer yet. */
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
quantity!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
|
||||
|
||||
/**
|
||||
* Stored slot status is *operator intent* only. Occupancy is never written
|
||||
* here: it is derived from `warehouse_inventory.slot_id` plus the row's
|
||||
* lifecycle status, so the two can never drift apart and no exit path
|
||||
* (load / dispatch / deliver) has to remember to free a slot. The computed
|
||||
* OCCUPIED value is what the API returns — see `SLOT_EFFECTIVE_STATUSES`.
|
||||
*/
|
||||
export const WAREHOUSE_ZONE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const;
|
||||
export type WarehouseZoneSlotStatus = (typeof WAREHOUSE_ZONE_SLOT_STATUSES)[number];
|
||||
|
||||
export const SLOT_EFFECTIVE_STATUSES = [...WAREHOUSE_ZONE_SLOT_STATUSES, 'OCCUPIED'] as const;
|
||||
export type SlotEffectiveStatus = (typeof SLOT_EFFECTIVE_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_zone_slots' })
|
||||
@Index(['stackId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseZoneSlot extends BaseEntity {
|
||||
@Column({ name: 'stack_id', type: 'uuid' })
|
||||
stackId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseZoneStack, (stack) => stack.slots, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'stack_id' })
|
||||
stack?: WarehouseZoneStack;
|
||||
|
||||
/** 1 = on the ground. Capped by the parent stack's maxStackHeight. */
|
||||
@Column({ name: 'level', type: 'int' })
|
||||
level!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'AVAILABLE' })
|
||||
status!: WarehouseZoneSlotStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
|
||||
|
||||
export const WAREHOUSE_ZONE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseZoneStackStatus = (typeof WAREHOUSE_ZONE_STACK_STATUSES)[number];
|
||||
|
||||
/** Default vertical height of a container stack — three boxes, EDR's reach-stacker limit. */
|
||||
export const DEFAULT_MAX_STACK_HEIGHT = 3;
|
||||
|
||||
/**
|
||||
* One ground footprint inside a zone: the patch of concrete a container is put
|
||||
* down on, and the levels above it. The zone is where allocation stops; this is
|
||||
* where a box physically sits.
|
||||
*
|
||||
* Generic on purpose — a bulk or general-cargo zone may divide itself into
|
||||
* stacks too — but the vertical stacking rules only run for CONTAINER_YARD.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_zone_stacks' })
|
||||
@Index(['zoneId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseZoneStack extends BaseEntity {
|
||||
@Column({ name: 'zone_id', type: 'uuid' })
|
||||
zoneId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseZone, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
/** Unique within the zone, e.g. ZA-001. */
|
||||
@Column({ name: 'code', type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160, nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
/** Free-form physical coordinates. Labels, not numbers — yards mix A/B/C with 1/2/3. */
|
||||
@Column({ name: 'row', type: 'varchar', length: 20, nullable: true })
|
||||
row?: string | null;
|
||||
|
||||
@Column({ name: 'bay', type: 'varchar', length: 20, nullable: true })
|
||||
bay?: string | null;
|
||||
|
||||
@Column({ name: 'position', type: 'varchar', length: 20, nullable: true })
|
||||
position?: string | null;
|
||||
|
||||
@Column({ name: 'max_stack_height', type: 'int', default: DEFAULT_MAX_STACK_HEIGHT })
|
||||
maxStackHeight!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseZoneStackStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => WarehouseZoneSlot, (slot) => slot.stack)
|
||||
slots?: WarehouseZoneSlot[];
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { FREIGHT_TYPES, FreightType } from '../../bookings/entities/booking.entity';
|
||||
import { Facility } from '../../facilities/entities/facility.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
|
||||
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
||||
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
||||
|
||||
export { FREIGHT_TYPES };
|
||||
export type { FreightType };
|
||||
|
||||
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
||||
|
||||
@@ -25,6 +29,14 @@ export class Warehouse extends BaseEntity {
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseType;
|
||||
|
||||
/**
|
||||
* What the warehouse handles. Null means unrestricted — the pre-existing
|
||||
* behaviour for every warehouse created before this field existed, so it
|
||||
* never narrows an already-configured site.
|
||||
*/
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
|
||||
freightType?: FreightType | null;
|
||||
|
||||
@Column({ name: 'station_id', type: 'uuid', nullable: true })
|
||||
stationId?: string | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import type { TrainLoadableItemRow } from './warehouse-inventory.service';
|
||||
|
||||
/**
|
||||
* Cargo may only go onto a wagon inside a STARTED loading window at its
|
||||
* boarding yard — the same rule the train schedule's own Load button enforces
|
||||
* (assertStationWorkStarted). The warehouse loading queues load through a
|
||||
* different service, so the rule is mirrored here; without it the two surfaces
|
||||
* disagree and the queue offers a Load the schedule would refuse.
|
||||
*
|
||||
* Only the DataSource is touched, so the instance is built off the prototype
|
||||
* rather than stubbing every collaborator.
|
||||
*/
|
||||
const row = (over: Partial<TrainLoadableItemRow> = {}): TrainLoadableItemRow =>
|
||||
({
|
||||
id: 'inv-1',
|
||||
bookingId: 'b-1',
|
||||
bookingReference: 'BK-1',
|
||||
customerName: 'Acme',
|
||||
containerNumber: 'CN-1',
|
||||
cargoType: 'General',
|
||||
weight: 20,
|
||||
grnNumber: 'GRN-1',
|
||||
inspectionStatus: 'PASSED',
|
||||
status: 'READY_FOR_LOADING',
|
||||
wagonId: 'w-1',
|
||||
wagonNumber: 'W-001',
|
||||
sequenceNo: 1,
|
||||
originYardId: 'yard-1',
|
||||
originYardLabel: 'Modjo',
|
||||
loadingWindowStarted: true,
|
||||
loadable: true,
|
||||
...over,
|
||||
}) as TrainLoadableItemRow;
|
||||
|
||||
function makeService(items: TrainLoadableItemRow[]) {
|
||||
const query = jest.fn().mockResolvedValue([
|
||||
{ trainNumber: 'T-100', origin: 'Modjo', destination: 'Djibouti', departure: null },
|
||||
]);
|
||||
const load = jest.fn().mockResolvedValue(undefined);
|
||||
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||
service.dataSource = { query };
|
||||
service.load = load;
|
||||
service.trainLoadableItems = jest.fn().mockResolvedValue(items);
|
||||
return { service: service as unknown as WarehouseInventoryService, load };
|
||||
}
|
||||
|
||||
describe('loadItemsOntoTrain() — station loading window gate', () => {
|
||||
it('skips an item whose boarding yard has no started loading window', async () => {
|
||||
const { service, load } = makeService([row({ loadingWindowStarted: false })]);
|
||||
|
||||
const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']);
|
||||
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
expect(result.loadedCount).toBe(0);
|
||||
expect(result.skippedCount).toBe(1);
|
||||
expect(result.results[0].reason).toContain('Start loading at Modjo first');
|
||||
});
|
||||
|
||||
it('loads once the window is started', async () => {
|
||||
const { service, load } = makeService([row()]);
|
||||
|
||||
const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(result.loadedCount).toBe(1);
|
||||
expect(result.skippedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('still reports the wagon blocker first — the window is not the only gate', async () => {
|
||||
const { service } = makeService([row({ wagonId: null, loadingWindowStarted: false })]);
|
||||
|
||||
const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']);
|
||||
|
||||
expect(result.results[0].reason).toContain('No wagon allocated');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
interface ItemAttributes {
|
||||
arrivedAt: Date | null;
|
||||
/** Backlog-registered box: real arrival on the record, but never billed. */
|
||||
backlogRegistration: boolean;
|
||||
gateClearedAt: Date | null;
|
||||
releaseDate: Date | null;
|
||||
freightType: string | null;
|
||||
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.backlog_registration AS "backlogRegistration",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.quantity AS "inventoryQuantity",
|
||||
@@ -777,6 +780,13 @@ export class WarehouseFeeService {
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
|
||||
// A backlog registration carries a backdated arrival so the record is
|
||||
// honest about how long the box has sat, but it was never booked through
|
||||
// EDR and is not billed for that history. No rule applies, so no preview —
|
||||
// which also keeps it off the invoice, since invoicing reads this same list.
|
||||
if (item.backlogRegistration) return [];
|
||||
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
|
||||
trucks.map(async (t) => {
|
||||
const item: ItemAttributes = {
|
||||
arrivedAt: null,
|
||||
// Truck detention is a per-truck charge, never a warehouse backlog row.
|
||||
backlogRegistration: false,
|
||||
gateClearedAt: null,
|
||||
releaseDate: null,
|
||||
freightType: leg.freightType ?? null,
|
||||
|
||||
@@ -14,8 +14,13 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { AssignSlotDto, FindAvailableSlotDto } from './dto/placement.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import {
|
||||
BulkRegisterBacklogDto,
|
||||
RegisterBacklogContainerDto,
|
||||
} from './dto/register-backlog.dto';
|
||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
@@ -143,6 +148,25 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.eligibleBookings(dir);
|
||||
}
|
||||
|
||||
@Post('register-backlog')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({
|
||||
summary: 'Register one loaded container already in the yard but never entered in the system',
|
||||
})
|
||||
registerBacklog(@Body() dto: RegisterBacklogContainerDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.inventoryService.registerBacklogContainer(dto);
|
||||
}
|
||||
|
||||
@Post('register-backlog-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-register loaded containers already in the yard (Excel backlog)' })
|
||||
registerBacklogBulk(@Body() dto: BulkRegisterBacklogDto, @CurrentUser() user: TCurrentUser) {
|
||||
const performedBy = actorLabel(user);
|
||||
dto.containers.forEach((c) => (c.performedBy = performedBy ?? c.performedBy));
|
||||
return this.inventoryService.bulkRegisterBacklogContainers(dto);
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
@@ -369,6 +393,50 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.move(id, dto);
|
||||
}
|
||||
|
||||
@Post('placement/find-slot')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({
|
||||
summary: 'Lowest free stack level for a container yard',
|
||||
description: 'Read-only preview of where the placement engine would put the next container.',
|
||||
})
|
||||
findAvailableSlot(@Body() dto: FindAvailableSlotDto) {
|
||||
return this.inventoryService.findAvailableSlot(dto);
|
||||
}
|
||||
|
||||
@Post(':id/assign-slot')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({
|
||||
summary: 'Place inventory at an exact stack level',
|
||||
description: 'Omit slotId to take the lowest free level in the item\'s current zone.',
|
||||
})
|
||||
assignSlot(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignSlotDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.assignSlot(id, dto.slotId, actorLabel(user));
|
||||
}
|
||||
|
||||
@Post(':id/release-slot')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({
|
||||
summary: 'Take inventory off its stack level',
|
||||
description: 'Refused while other containers are stacked on top of it.',
|
||||
})
|
||||
releaseSlot(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.inventoryService.releaseSlot(id, actorLabel(user));
|
||||
}
|
||||
|
||||
@Get(':id/accessibility')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({
|
||||
summary: 'Can this container be lifted out',
|
||||
description: 'Lists the containers stacked above it. Nothing is moved.',
|
||||
})
|
||||
accessibility(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.getContainerAccessibility(id);
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,10 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceLine } from "../billing/entities/invoice-line.entity";
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
||||
import { settlementReferences } from "../billing/invoice-settlement.util";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
InvoiceDocumentService,
|
||||
} from "../billing/documents/invoice-document.service";
|
||||
import { NotificationsService } from "../notifications/notifications.service";
|
||||
@@ -71,6 +73,11 @@ const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
|
||||
export interface InvoiceDocumentDetails {
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
/**
|
||||
* Trade name of the eTrade licence the billed company profile operates as.
|
||||
* Null when nothing is attached, or for a company with no eTrade record.
|
||||
*/
|
||||
customerTradeName: string | null;
|
||||
inventoryReference: string | null;
|
||||
inventoryInfo: string | null;
|
||||
inventoryStatus: string | null;
|
||||
@@ -745,6 +752,17 @@ export class WarehouseInvoiceService {
|
||||
},
|
||||
{ label: "Booking reference", value: invoice.bookingReference ?? null },
|
||||
{ label: "Customer", value: invoice.customerName ?? null },
|
||||
// Which of the TIN's eTrade businesses was billed. Omitted when it just
|
||||
// repeats the customer name — see sameCompanyName.
|
||||
...(invoice.customerTradeName &&
|
||||
!sameCompanyName(invoice.customerTradeName, invoice.customerName)
|
||||
? [
|
||||
{
|
||||
label: "Customer trade name",
|
||||
value: invoice.customerTradeName,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Inventory reference",
|
||||
value: invoice.inventoryReference ?? null,
|
||||
@@ -768,6 +786,16 @@ export class WarehouseInvoiceService {
|
||||
? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}`
|
||||
: null,
|
||||
},
|
||||
// The provider's own transaction number (CBE `FT…`, telebirr receipt no., a
|
||||
// teller's bank-slip ref) — the row above says only HOW and WHEN it was paid,
|
||||
// which nobody can reconcile a bank statement against. The warehouse view
|
||||
// projects the invoice ledger but not the linked gateway `payments` row, so the
|
||||
// ledger is the only source here; it carries the provider ref on every path
|
||||
// that has one.
|
||||
{
|
||||
label: "Transaction ref",
|
||||
value: settlementReferences({ payments: invoice.payments }),
|
||||
},
|
||||
],
|
||||
categoryHeader: "Fee type",
|
||||
lines: invoice.items.map((item) => ({
|
||||
@@ -795,6 +823,7 @@ export class WarehouseInvoiceService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
cp.etrade_business->>'tradeName' AS "customerTradeName",
|
||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
@@ -812,6 +841,7 @@ export class WarehouseInvoiceService {
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.company_profiles cp ON cp.id = b.company_profile_id AND cp.deleted_at IS NULL
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
@@ -837,6 +867,7 @@ export class WarehouseInvoiceService {
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
customerTradeName: row?.customerTradeName ?? null,
|
||||
inventoryReference: row?.inventoryReference ?? null,
|
||||
inventoryInfo: row?.inventoryInfo ?? null,
|
||||
inventoryStatus: row?.inventoryStatus ?? null,
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
SLOT_OCCUPYING_STATUSES,
|
||||
WarehouseInventory,
|
||||
} from './entities/warehouse-inventory.entity';
|
||||
import { SlotEffectiveStatus } from './entities/warehouse-zone-slot.entity';
|
||||
|
||||
/** The whole chain above one slot, resolved server-side in a single join. */
|
||||
export interface SlotHierarchy {
|
||||
slotId: string;
|
||||
slotStatus: string;
|
||||
slotIsActive: boolean;
|
||||
level: number;
|
||||
stackId: string;
|
||||
stackCode: string;
|
||||
stackStatus: string;
|
||||
stackIsActive: boolean;
|
||||
maxStackHeight: number;
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
zoneType: string;
|
||||
zoneStatus: string;
|
||||
zoneIsActive: boolean;
|
||||
yardId: string;
|
||||
yardCode: string;
|
||||
yardType: string;
|
||||
yardDirection: string | null;
|
||||
yardStatus: string;
|
||||
yardIsActive: boolean;
|
||||
warehouseId: string;
|
||||
warehouseCode: string;
|
||||
warehouseStatus: string;
|
||||
warehouseIsActive: boolean;
|
||||
}
|
||||
|
||||
export interface AvailableSlot {
|
||||
slotId: string;
|
||||
stackId: string;
|
||||
stackCode: string;
|
||||
level: number;
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
}
|
||||
|
||||
export interface FindSlotInput {
|
||||
yardId: string;
|
||||
zoneId?: string | null;
|
||||
/** IMPORT | EXPORT — matched against the yard's direction (null = BOTH). */
|
||||
direction?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
}
|
||||
|
||||
export interface BlockingContainer {
|
||||
inventoryId: string;
|
||||
containerNumber: string | null;
|
||||
level: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ContainerAccessibility {
|
||||
accessible: boolean;
|
||||
inventoryId: string;
|
||||
stackCode: string | null;
|
||||
level: number | null;
|
||||
blockingContainers: BlockingContainer[];
|
||||
}
|
||||
|
||||
export interface SlotSummary {
|
||||
configuredCapacity: number | null;
|
||||
physicalSlotCount: number;
|
||||
occupiedSlotCount: number;
|
||||
reservedSlotCount: number;
|
||||
blockedSlotCount: number;
|
||||
inactiveSlotCount: number;
|
||||
availableSlotCount: number;
|
||||
/** True when more physical slots are built than the configured capacity allows. */
|
||||
inconsistent: boolean;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutSlot {
|
||||
slotId: string;
|
||||
level: number;
|
||||
effectiveStatus: SlotEffectiveStatus;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutStack {
|
||||
stackId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxStackHeight: number;
|
||||
status: string;
|
||||
isActive: boolean;
|
||||
slots: ZoneLayoutSlot[];
|
||||
}
|
||||
|
||||
export interface ZoneLayout {
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
zoneName: string;
|
||||
stacks: ZoneLayoutStack[];
|
||||
summary: SlotSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container identity has two sources and neither covers the other: a backlog
|
||||
* registration points `warehouse_inventory.container_id` at a `containers` row,
|
||||
* while booked cargo carries its numbers on `booking_container_units`. Scalar
|
||||
* subselects rather than joins, so one slot can never fan out into many rows.
|
||||
* A booking whose units were never split into one inventory row each shows the
|
||||
* first unit number — placement refuses such rows anyway (see assertSingleUnit).
|
||||
*/
|
||||
const CONTAINER_NUMBER_EXPR = `COALESCE(
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.id = i.container_id AND c.deleted_at IS NULL),
|
||||
(SELECT bcu.container_number FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = i.booking_id AND bcu.deleted_at IS NULL
|
||||
ORDER BY bcu.container_number
|
||||
LIMIT 1)
|
||||
)`;
|
||||
|
||||
/** A row is "standing in its slot" only in these statuses — same list as the DB's partial unique index. */
|
||||
const OCCUPYING = SLOT_OCCUPYING_STATUSES as unknown as string[];
|
||||
|
||||
/**
|
||||
* Physical container placement: the stage after allocation. Allocation picks a
|
||||
* yard (and maybe a zone) from configured rules; this picks the exact stack and
|
||||
* level, enforces the stacking rules, and answers whether a box can be reached.
|
||||
*
|
||||
* Nothing here is called for a non-container yard — bulk, general cargo,
|
||||
* hazardous and cold storage keep zone-level placement.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehousePlacementService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
private em(manager?: EntityManager): EntityManager | DataSource {
|
||||
return manager ?? this.dataSource;
|
||||
}
|
||||
|
||||
// ── Hierarchy ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a slot's full chain up to the warehouse. Ids arriving from a client
|
||||
* are never trusted against one another — this is the one place the chain is
|
||||
* established, and every caller compares against what comes back here.
|
||||
*/
|
||||
async resolveSlot(slotId: string, manager?: EntityManager): Promise<SlotHierarchy> {
|
||||
const [row] = await this.em(manager).query(
|
||||
`SELECT sl.id AS "slotId", sl.status AS "slotStatus", sl.is_active AS "slotIsActive",
|
||||
sl.level AS "level",
|
||||
s.id AS "stackId", s.code AS "stackCode", s.status AS "stackStatus",
|
||||
s.is_active AS "stackIsActive", s.max_stack_height AS "maxStackHeight",
|
||||
z.id AS "zoneId", z.code AS "zoneCode", z.type AS "zoneType",
|
||||
z.status AS "zoneStatus", z.is_active AS "zoneIsActive",
|
||||
y.id AS "yardId", y.code AS "yardCode", y.type AS "yardType",
|
||||
y.direction AS "yardDirection", y.status AS "yardStatus", y.is_active AS "yardIsActive",
|
||||
w.id AS "warehouseId", w.code AS "warehouseCode",
|
||||
w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive"
|
||||
FROM freight.warehouse_zone_slots sl
|
||||
JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL
|
||||
JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL
|
||||
JOIN freight.warehouse_yards y ON y.id = z.yard_id AND y.deleted_at IS NULL
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL
|
||||
WHERE sl.id = $1 AND sl.deleted_at IS NULL`,
|
||||
[slotId],
|
||||
);
|
||||
|
||||
if (!row) throw new NotFoundException(`Slot ${slotId} not found`);
|
||||
return row as SlotHierarchy;
|
||||
}
|
||||
|
||||
/** Levels in a stack currently holding a container, lowest first. */
|
||||
async occupiedLevels(
|
||||
stackId: string,
|
||||
excludeInventoryId?: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<number[]> {
|
||||
const rows: Array<{ level: number }> = await this.em(manager).query(
|
||||
`SELECT sl.level AS "level"
|
||||
FROM freight.warehouse_zone_slots sl
|
||||
JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2)
|
||||
WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL
|
||||
AND ($3::uuid IS NULL OR i.id <> $3::uuid)
|
||||
ORDER BY sl.level`,
|
||||
[stackId, OCCUPYING, excludeInventoryId ?? null],
|
||||
);
|
||||
return rows.map((r) => Number(r.level));
|
||||
}
|
||||
|
||||
// ── Placement validation ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every check that must pass before a container may stand in a slot, in the
|
||||
* order a yard operator would hit them. Returns the resolved hierarchy so the
|
||||
* caller writes ids it did not invent.
|
||||
*/
|
||||
async validateSlotForInventory(
|
||||
input: {
|
||||
slotId: string;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
/** Excluded from occupancy checks — the row being moved is allowed to leave its own slot. */
|
||||
inventoryId?: string | null;
|
||||
quantity?: number | null;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<SlotHierarchy> {
|
||||
const slot = await this.resolveSlot(input.slotId, manager);
|
||||
|
||||
// 1. Hierarchy — the client may not staple a slot onto an unrelated zone/yard/warehouse.
|
||||
if (slot.zoneId !== input.zoneId) {
|
||||
throw new BadRequestException(
|
||||
`Slot ${slot.stackCode}/L${slot.level} belongs to zone ${slot.zoneCode}, not the zone given`,
|
||||
);
|
||||
}
|
||||
if (slot.yardId !== input.yardId) {
|
||||
throw new BadRequestException(`Zone ${slot.zoneCode} belongs to yard ${slot.yardCode}, not the yard given`);
|
||||
}
|
||||
if (slot.warehouseId !== input.warehouseId) {
|
||||
throw new BadRequestException(
|
||||
`Yard ${slot.yardCode} belongs to warehouse ${slot.warehouseCode}, not the warehouse given`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Every level of the chain has to be operationally open.
|
||||
this.assertOperational('Warehouse', slot.warehouseCode, slot.warehouseStatus, slot.warehouseIsActive);
|
||||
this.assertOperational('Yard', slot.yardCode, slot.yardStatus, slot.yardIsActive);
|
||||
this.assertOperational('Zone', slot.zoneCode, slot.zoneStatus, slot.zoneIsActive);
|
||||
this.assertOperational('Stack', slot.stackCode, slot.stackStatus, slot.stackIsActive);
|
||||
|
||||
if (!slot.slotIsActive) {
|
||||
throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is inactive`);
|
||||
}
|
||||
// RESERVED is accepted: a slot is reserved *for* the box now arriving.
|
||||
if (slot.slotStatus !== 'AVAILABLE' && slot.slotStatus !== 'RESERVED') {
|
||||
throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is ${slot.slotStatus}`);
|
||||
}
|
||||
|
||||
// 3. One box per slot. The DB's partial unique index is the backstop; this
|
||||
// is the readable error the operator actually gets.
|
||||
const [taken] = await this.em(manager).query(
|
||||
`SELECT i.id FROM freight.warehouse_inventory i
|
||||
WHERE i.slot_id = $1 AND i.deleted_at IS NULL AND i.status = ANY($2)
|
||||
AND ($3::uuid IS NULL OR i.id <> $3::uuid)
|
||||
LIMIT 1`,
|
||||
[input.slotId, OCCUPYING, input.inventoryId ?? null],
|
||||
);
|
||||
if (taken) {
|
||||
throw new ConflictException(`Slot ${slot.stackCode}/L${slot.level} is already occupied`);
|
||||
}
|
||||
|
||||
if (slot.level > slot.maxStackHeight) {
|
||||
throw new BadRequestException(
|
||||
`Level ${slot.level} is above stack ${slot.stackCode}'s maximum height of ${slot.maxStackHeight}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Container yards only: no box may float above an empty level, and a row
|
||||
// covering several containers has no single physical position.
|
||||
if (slot.yardType === 'CONTAINER_YARD') {
|
||||
this.assertSingleUnit(input.quantity);
|
||||
const occupied = await this.occupiedLevels(slot.stackId, input.inventoryId ?? null, manager);
|
||||
this.assertStackable(slot, occupied);
|
||||
}
|
||||
|
||||
return slot;
|
||||
}
|
||||
|
||||
private assertOperational(label: string, code: string, status: string, isActive: boolean): void {
|
||||
if (status !== 'ACTIVE' || !isActive) {
|
||||
throw new BadRequestException(`${label} ${code} is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot is one container. A row that still carries several boxes has no
|
||||
* single position — split it before placing it, rather than silently pinning
|
||||
* five containers to one level.
|
||||
*/
|
||||
private assertSingleUnit(quantity?: number | null): void {
|
||||
const qty = Number(quantity ?? 1);
|
||||
if (qty > 1) {
|
||||
throw new BadRequestException(
|
||||
`This inventory row covers ${qty} containers. Split it into one row per container before assigning a slot.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Level N needs every level below it filled — nothing hovers. */
|
||||
assertStackable(slot: Pick<SlotHierarchy, 'level' | 'stackCode'>, occupiedLevels: number[]): void {
|
||||
if (slot.level === 1) return;
|
||||
const missing: number[] = [];
|
||||
for (let level = 1; level < slot.level; level += 1) {
|
||||
if (!occupiedLevels.includes(level)) missing.push(level);
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Stack ${slot.stackCode}: level ${slot.level} cannot be filled while level(s) ${missing.join(', ')} are empty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Finding a slot ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lowest valid free level, deterministic: zone code, then stack code, then
|
||||
* level. Bottom-up by construction — a stack's candidate level is always one
|
||||
* above its current top, so level 2 can never be picked before level 1.
|
||||
*
|
||||
* Isolated on purpose: a smarter strategy (weight, direction, dwell time)
|
||||
* swaps in here without touching any caller.
|
||||
*/
|
||||
async findAvailableContainerSlot(input: FindSlotInput, manager?: EntityManager): Promise<AvailableSlot | null> {
|
||||
const yard = await this.loadYardForPlacement(input, manager);
|
||||
const zoneIds = await this.candidateZoneIds(yard.id, input.zoneId ?? null, manager);
|
||||
if (zoneIds.length === 0) return null;
|
||||
|
||||
const [slot] = await this.em(manager).query(
|
||||
`SELECT sl.id AS "slotId", s.id AS "stackId", s.code AS "stackCode",
|
||||
sl.level AS "level", z.id AS "zoneId", z.code AS "zoneCode"
|
||||
FROM freight.warehouse_zone_stacks s
|
||||
JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT COALESCE(MAX(sl2.level), 0) AS top
|
||||
FROM freight.warehouse_zone_slots sl2
|
||||
JOIN freight.warehouse_inventory i2
|
||||
ON i2.slot_id = sl2.id AND i2.deleted_at IS NULL AND i2.status = ANY($2)
|
||||
WHERE sl2.stack_id = s.id AND sl2.deleted_at IS NULL
|
||||
) occ
|
||||
JOIN freight.warehouse_zone_slots sl
|
||||
ON sl.stack_id = s.id AND sl.deleted_at IS NULL
|
||||
AND sl.level = occ.top + 1
|
||||
AND sl.status = 'AVAILABLE' AND sl.is_active = true
|
||||
WHERE s.zone_id = ANY($1::uuid[])
|
||||
AND s.deleted_at IS NULL AND s.status = 'ACTIVE' AND s.is_active = true
|
||||
AND occ.top < s.max_stack_height
|
||||
ORDER BY z.code, s.code, sl.level
|
||||
LIMIT 1`,
|
||||
[zoneIds, OCCUPYING],
|
||||
);
|
||||
|
||||
return (slot as AvailableSlot) ?? null;
|
||||
}
|
||||
|
||||
/** Yard gates: active, a container yard, right direction, right cargo type. */
|
||||
private async loadYardForPlacement(
|
||||
input: FindSlotInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<{ id: string; code: string }> {
|
||||
const [yard] = await this.em(manager).query(
|
||||
`SELECT y.id, y.code, y.type, y.direction, y.status, y.is_active AS "isActive",
|
||||
y.capacity_containers AS "capacityContainers", y.current_containers AS "currentContainers",
|
||||
w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive", w.code AS "warehouseCode"
|
||||
FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL
|
||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[input.yardId],
|
||||
);
|
||||
if (!yard) throw new NotFoundException(`Yard ${input.yardId} not found`);
|
||||
|
||||
this.assertOperational('Warehouse', yard.warehouseCode, yard.warehouseStatus, yard.warehouseIsActive);
|
||||
this.assertOperational('Yard', yard.code, yard.status, yard.isActive);
|
||||
|
||||
if (yard.type !== 'CONTAINER_YARD') {
|
||||
throw new BadRequestException(`Yard ${yard.code} is a ${yard.type}; container stacking does not apply`);
|
||||
}
|
||||
|
||||
// Null direction has always meant "takes both" — never treat it as invalid.
|
||||
const yardDirection = yard.direction ?? 'BOTH';
|
||||
const wanted = input.direction ?? 'BOTH';
|
||||
if (yardDirection !== 'BOTH' && wanted !== 'BOTH' && yardDirection !== wanted) {
|
||||
throw new BadRequestException(`Yard ${yard.code} serves ${yardDirection} traffic, not ${wanted}`);
|
||||
}
|
||||
|
||||
// Empty cargo-type relation = open to any cargo. Preserved deliberately.
|
||||
if (input.cargoTypeId) {
|
||||
const [{ allowed }] = await this.em(manager).query(
|
||||
`SELECT (NOT EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t WHERE t.yard_id = $1)
|
||||
OR EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t
|
||||
WHERE t.yard_id = $1 AND t.cargo_type_id = $2)) AS allowed`,
|
||||
[yard.id, input.cargoTypeId],
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new BadRequestException(`Yard ${yard.code} does not accept this cargo type`);
|
||||
}
|
||||
}
|
||||
|
||||
if (yard.capacityContainers != null && Number(yard.currentContainers) >= Number(yard.capacityContainers)) {
|
||||
throw new BadRequestException(
|
||||
`Yard ${yard.code} is at its configured capacity (${yard.currentContainers}/${yard.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { id: yard.id, code: yard.code };
|
||||
}
|
||||
|
||||
/** Active container zones in the yard with configured capacity left, in code order. */
|
||||
private async candidateZoneIds(
|
||||
yardId: string,
|
||||
zoneId: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<string[]> {
|
||||
const rows: Array<{ id: string }> = await this.em(manager).query(
|
||||
`SELECT z.id
|
||||
FROM freight.warehouse_zones z
|
||||
WHERE z.yard_id = $1 AND z.deleted_at IS NULL
|
||||
AND z.status = 'ACTIVE' AND z.is_active = true
|
||||
AND z.type = 'CONTAINER_ZONE'
|
||||
AND (z.capacity_containers IS NULL OR z.current_containers < z.capacity_containers)
|
||||
AND ($2::uuid IS NULL OR z.id = $2::uuid)
|
||||
ORDER BY z.code`,
|
||||
[yardId, zoneId],
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// ── Accessibility ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether a box can be taken out without touching anything else. Containers
|
||||
* standing above it block it; nothing is moved to clear the way — a
|
||||
* relocation is an operator decision, not a side effect of a read.
|
||||
*/
|
||||
async getContainerAccessibility(inventoryId: string, manager?: EntityManager): Promise<ContainerAccessibility> {
|
||||
const [placed] = await this.em(manager).query(
|
||||
`SELECT i.id AS "inventoryId", sl.level AS "level", s.id AS "stackId", s.code AS "stackCode"
|
||||
FROM freight.warehouse_inventory i
|
||||
LEFT JOIN freight.warehouse_zone_slots sl ON sl.id = i.slot_id AND sl.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL
|
||||
WHERE i.id = $1 AND i.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
|
||||
if (!placed) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// No slot = zone-level placement (bulk, or an item that predates the model):
|
||||
// nothing is stacked on it, so it is reachable.
|
||||
if (!placed.stackId) {
|
||||
return { accessible: true, inventoryId, stackCode: null, level: null, blockingContainers: [] };
|
||||
}
|
||||
|
||||
const blocking: BlockingContainer[] = await this.em(manager).query(
|
||||
`SELECT i.id AS "inventoryId", sl.level AS "level", i.status AS "status",
|
||||
${CONTAINER_NUMBER_EXPR} AS "containerNumber"
|
||||
FROM freight.warehouse_zone_slots sl
|
||||
JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3)
|
||||
WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL AND sl.level > $2
|
||||
ORDER BY sl.level DESC`,
|
||||
[placed.stackId, Number(placed.level), OCCUPYING],
|
||||
);
|
||||
|
||||
return {
|
||||
accessible: blocking.length === 0,
|
||||
inventoryId,
|
||||
stackCode: placed.stackCode,
|
||||
level: Number(placed.level),
|
||||
blockingContainers: blocking.map((b) => ({ ...b, level: Number(b.level) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuse to hand out a box that is buried — used by the exit/delivery paths. */
|
||||
async assertAccessible(inventoryId: string, manager?: EntityManager): Promise<void> {
|
||||
const access = await this.getContainerAccessibility(inventoryId, manager);
|
||||
if (!access.accessible) {
|
||||
const above = access.blockingContainers
|
||||
.map((b) => `${b.containerNumber ?? b.inventoryId} (L${b.level})`)
|
||||
.join(', ');
|
||||
throw new ConflictException(
|
||||
`Container is at ${access.stackCode}/L${access.level} with ${above} stacked above it. Relocate those first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Physical layout of one zone: every stack, every level, what stands there. */
|
||||
async zoneLayout(zoneId: string, manager?: EntityManager): Promise<ZoneLayout> {
|
||||
const [zone] = await this.em(manager).query(
|
||||
`SELECT z.id, z.code, z.name, z.capacity_containers AS "capacityContainers"
|
||||
FROM freight.warehouse_zones z WHERE z.id = $1 AND z.deleted_at IS NULL`,
|
||||
[zoneId],
|
||||
);
|
||||
if (!zone) throw new NotFoundException(`Warehouse zone ${zoneId} not found`);
|
||||
|
||||
const rows: Array<{
|
||||
stackId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxStackHeight: number;
|
||||
stackStatus: string;
|
||||
stackIsActive: boolean;
|
||||
slotId: string | null;
|
||||
level: number | null;
|
||||
slotStatus: string | null;
|
||||
slotIsActive: boolean | null;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
}> = await this.em(manager).query(
|
||||
`SELECT s.id AS "stackId", s.code AS "code", s.name AS "name",
|
||||
s.max_stack_height AS "maxStackHeight", s.status AS "stackStatus",
|
||||
s.is_active AS "stackIsActive",
|
||||
sl.id AS "slotId", sl.level AS "level", sl.status AS "slotStatus",
|
||||
sl.is_active AS "slotIsActive",
|
||||
i.id AS "inventoryId",
|
||||
CASE WHEN i.id IS NULL THEN NULL ELSE ${CONTAINER_NUMBER_EXPR} END AS "containerNumber"
|
||||
FROM freight.warehouse_zone_stacks s
|
||||
LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2)
|
||||
WHERE s.zone_id = $1 AND s.deleted_at IS NULL
|
||||
ORDER BY s.code, sl.level DESC`,
|
||||
[zoneId, OCCUPYING],
|
||||
);
|
||||
|
||||
const stacks = new Map<string, ZoneLayoutStack>();
|
||||
for (const row of rows) {
|
||||
let stack = stacks.get(row.stackId);
|
||||
if (!stack) {
|
||||
stack = {
|
||||
stackId: row.stackId,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
maxStackHeight: Number(row.maxStackHeight),
|
||||
status: row.stackStatus,
|
||||
isActive: row.stackIsActive,
|
||||
slots: [],
|
||||
};
|
||||
stacks.set(row.stackId, stack);
|
||||
}
|
||||
if (row.slotId) {
|
||||
stack.slots.push({
|
||||
slotId: row.slotId,
|
||||
level: Number(row.level),
|
||||
effectiveStatus: this.effectiveStatus(row.slotStatus, row.slotIsActive, row.inventoryId),
|
||||
inventoryId: row.inventoryId,
|
||||
containerNumber: row.containerNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
zoneId: zone.id,
|
||||
zoneCode: zone.code,
|
||||
zoneName: zone.name,
|
||||
stacks: [...stacks.values()],
|
||||
summary: await this.slotSummary({ zoneId }, manager),
|
||||
};
|
||||
}
|
||||
|
||||
private effectiveStatus(
|
||||
status: string | null,
|
||||
isActive: boolean | null,
|
||||
inventoryId: string | null,
|
||||
): SlotEffectiveStatus {
|
||||
if (inventoryId) return 'OCCUPIED';
|
||||
if (isActive === false) return 'INACTIVE';
|
||||
return (status as SlotEffectiveStatus) ?? 'AVAILABLE';
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers that are routinely confused: what was configured, what is
|
||||
* physically built, and what is actually full. Configured capacity is never
|
||||
* overwritten from the slot count — a mismatch is reported, not corrected.
|
||||
*/
|
||||
async slotSummary(
|
||||
scope: { zoneId?: string; yardId?: string },
|
||||
manager?: EntityManager,
|
||||
): Promise<SlotSummary> {
|
||||
if (!scope.zoneId && !scope.yardId) {
|
||||
throw new BadRequestException('A zone or yard is required');
|
||||
}
|
||||
|
||||
const [row] = await this.em(manager).query(
|
||||
`SELECT
|
||||
(SELECT SUM(z.capacity_containers)
|
||||
FROM freight.warehouse_zones z
|
||||
WHERE z.deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR z.id = $1::uuid)
|
||||
AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)) AS "configuredCapacity",
|
||||
COUNT(sl.id) AS "physicalSlotCount",
|
||||
COUNT(i.id) AS "occupiedSlotCount",
|
||||
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'RESERVED') AS "reservedSlotCount",
|
||||
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'BLOCKED') AS "blockedSlotCount",
|
||||
COUNT(*) FILTER (WHERE sl.id IS NOT NULL AND (NOT sl.is_active OR sl.status = 'INACTIVE'))
|
||||
AS "inactiveSlotCount",
|
||||
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'AVAILABLE'
|
||||
AND s.status = 'ACTIVE' AND s.is_active) AS "availableSlotCount"
|
||||
FROM freight.warehouse_zones z
|
||||
JOIN freight.warehouse_zone_stacks s ON s.zone_id = z.id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3)
|
||||
WHERE z.deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR z.id = $1::uuid)
|
||||
AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)`,
|
||||
[scope.zoneId ?? null, scope.yardId ?? null, OCCUPYING],
|
||||
);
|
||||
|
||||
const configuredCapacity = row?.configuredCapacity == null ? null : Number(row.configuredCapacity);
|
||||
const physicalSlotCount = Number(row?.physicalSlotCount ?? 0);
|
||||
|
||||
return {
|
||||
configuredCapacity,
|
||||
physicalSlotCount,
|
||||
occupiedSlotCount: Number(row?.occupiedSlotCount ?? 0),
|
||||
reservedSlotCount: Number(row?.reservedSlotCount ?? 0),
|
||||
blockedSlotCount: Number(row?.blockedSlotCount ?? 0),
|
||||
inactiveSlotCount: Number(row?.inactiveSlotCount ?? 0),
|
||||
availableSlotCount: Number(row?.availableSlotCount ?? 0),
|
||||
inconsistent: configuredCapacity != null && physicalSlotCount > configuredCapacity,
|
||||
};
|
||||
}
|
||||
|
||||
/** Free a slot explicitly. Exit paths don't need this — status alone frees it. */
|
||||
async releaseSlot(inventoryId: string, manager?: EntityManager): Promise<void> {
|
||||
await this.em(manager).query(
|
||||
`UPDATE freight.warehouse_inventory
|
||||
SET stack_id = NULL, slot_id = NULL, updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Write a validated placement onto an inventory row inside the caller's transaction. */
|
||||
async applyPlacement(
|
||||
manager: EntityManager,
|
||||
inventoryId: string,
|
||||
placement: { stackId: string; slotId: string } | null,
|
||||
): Promise<void> {
|
||||
await manager.getRepository(WarehouseInventory).update(inventoryId, {
|
||||
stackId: placement?.stackId ?? null,
|
||||
slotId: placement?.slotId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
@@ -40,6 +40,16 @@ export class WarehouseYardsController {
|
||||
return this.yardsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.delete)
|
||||
@ApiOperation({
|
||||
summary: 'Delete warehouse yard',
|
||||
description: 'Soft-deletes the yard. Refused while it still has zones.',
|
||||
})
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.yardsService.remove(id);
|
||||
}
|
||||
|
||||
@Get(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
@ApiOperation({ summary: 'List zones within a yard' })
|
||||
|
||||
@@ -107,6 +107,24 @@ export class WarehouseYardsService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a yard. Zones (and the inventory sitting in them) are left
|
||||
* alone — a yard still holding zones is refused rather than orphaning stock.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (existing.zones?.length) {
|
||||
throw new ConflictException(
|
||||
`Yard ${existing.code} still has ${existing.zones.length} zone(s). Delete them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.yardsRepository.softDelete(id);
|
||||
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZoneSlotsRepository extends BaseRepository<WarehouseZoneSlot> {
|
||||
constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository<WarehouseZoneSlot>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
CreateWarehouseZoneStackDto,
|
||||
UpdateWarehouseZoneSlotDto,
|
||||
UpdateWarehouseZoneStackDto,
|
||||
} from './dto/warehouse-zone-stack.dto';
|
||||
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
|
||||
|
||||
/**
|
||||
* Stacks and slots are zone configuration, so they ride the warehouse-zone
|
||||
* permissions rather than introducing new keys — a new key needs a matching
|
||||
* `iam.permissions` row in every environment or boot fails.
|
||||
*/
|
||||
@ApiTags('warehouse-zone-stacks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zone-stacks')
|
||||
// Class gate lists every key its routes use: Nest runs class AND method guards.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseZones.create,
|
||||
FREIGHT_PERMS.warehouseZones.update,
|
||||
FREIGHT_PERMS.warehouseZones.delete,
|
||||
])
|
||||
export class WarehouseZoneStacksController {
|
||||
constructor(private readonly stacksService: WarehouseZoneStacksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List the ground stacks configured in a zone' })
|
||||
findByZone(@Query('zoneId', ParseUUIDPipe) zoneId: string) {
|
||||
return this.stacksService.findByZone(zoneId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
|
||||
@ApiOperation({
|
||||
summary: 'Create a ground stack',
|
||||
description: 'One slot per level is generated automatically, from 1 to maxStackHeight (default 3).',
|
||||
})
|
||||
create(@Body() dto: CreateWarehouseZoneStackDto, @Query('zoneId') zoneIdQuery?: string) {
|
||||
const zoneId = dto.zoneId ?? zoneIdQuery;
|
||||
if (!zoneId) {
|
||||
throw new BadRequestException('zoneId is required');
|
||||
}
|
||||
return this.stacksService.create(zoneId, dto);
|
||||
}
|
||||
|
||||
// Declared before ':id' so 'slots' is never swallowed as a stack id.
|
||||
@Patch('slots/:slotId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({
|
||||
summary: 'Block, reserve, or reactivate one slot',
|
||||
description: 'Occupancy is derived from inventory and cannot be set here.',
|
||||
})
|
||||
updateSlot(@Param('slotId', ParseUUIDPipe) slotId: string, @Body() dto: UpdateWarehouseZoneSlotDto) {
|
||||
return this.stacksService.updateSlot(slotId, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one stack with its slots' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.stacksService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/occupancy')
|
||||
@ApiOperation({ summary: 'Level-by-level occupancy of one stack' })
|
||||
occupancy(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.stacksService.slotOccupancy(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({
|
||||
summary: 'Update a stack',
|
||||
description: 'Raising maxStackHeight adds slots; lowering it trims the empty top levels.',
|
||||
})
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneStackDto) {
|
||||
return this.stacksService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.delete)
|
||||
@ApiOperation({ summary: 'Delete a stack', description: 'Refused while containers still stand in it.' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.stacksService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZoneStacksRepository extends BaseRepository<WarehouseZoneStack> {
|
||||
constructor(@InjectRepository(WarehouseZoneStack) repository: Repository<WarehouseZoneStack>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateWarehouseZoneStackDto,
|
||||
UpdateWarehouseZoneSlotDto,
|
||||
UpdateWarehouseZoneStackDto,
|
||||
} from './dto/warehouse-zone-stack.dto';
|
||||
import {
|
||||
DEFAULT_MAX_STACK_HEIGHT,
|
||||
WarehouseZoneStack,
|
||||
} from './entities/warehouse-zone-stack.entity';
|
||||
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
|
||||
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||
import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository';
|
||||
import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
/**
|
||||
* Ground stacks and their vertical slots — the physical layout of a zone.
|
||||
*
|
||||
* Slots are never created by hand: a stack of height 3 is three slots, so they
|
||||
* are generated with the stack and kept in step with its height. That is the
|
||||
* only way the placement engine can trust `level` to mean what it says.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseZoneStacksService {
|
||||
constructor(
|
||||
private readonly stacksRepository: WarehouseZoneStacksRepository,
|
||||
private readonly slotsRepository: WarehouseZoneSlotsRepository,
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
private readonly placement: WarehousePlacementService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findByZone(zoneId: string): Promise<WarehouseZoneStack[]> {
|
||||
return this.stacksRepository.findAll({
|
||||
where: { zoneId },
|
||||
relations: { slots: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseZoneStack> {
|
||||
const stack = await this.stacksRepository.findById(id, { relations: { slots: true, zone: true } });
|
||||
if (!stack) throw new NotFoundException(`Warehouse zone stack ${id} not found`);
|
||||
stack.slots?.sort((a, b) => a.level - b.level);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/** Create the stack and its slots together — a stack with no slots holds nothing. */
|
||||
async create(zoneId: string, dto: CreateWarehouseZoneStackDto): Promise<WarehouseZoneStack> {
|
||||
await this.zonesService.findById(zoneId);
|
||||
const code = dto.code.trim();
|
||||
await this.assertCodeUnique(zoneId, code);
|
||||
|
||||
const maxStackHeight = dto.maxStackHeight ?? DEFAULT_MAX_STACK_HEIGHT;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const stack = await manager.getRepository(WarehouseZoneStack).save(
|
||||
manager.getRepository(WarehouseZoneStack).create({
|
||||
zoneId,
|
||||
code,
|
||||
name: dto.name?.trim() ?? null,
|
||||
row: dto.row?.trim() ?? null,
|
||||
bay: dto.bay?.trim() ?? null,
|
||||
position: dto.position?.trim() ?? null,
|
||||
maxStackHeight,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.generateSlots(manager, stack.id, 1, maxStackHeight);
|
||||
return stack.id;
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseZoneStackDto): Promise<WarehouseZoneStack> {
|
||||
const existing = await this.findById(id);
|
||||
const code = dto.code?.trim() ?? existing.code;
|
||||
|
||||
if (code !== existing.code) {
|
||||
await this.assertCodeUnique(existing.zoneId, code, id);
|
||||
}
|
||||
|
||||
const newHeight = dto.maxStackHeight ?? existing.maxStackHeight;
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
if (status === 'INACTIVE' && existing.status !== 'INACTIVE') {
|
||||
await this.assertStackEmpty(id, 'deactivated');
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (newHeight > existing.maxStackHeight) {
|
||||
await this.generateSlots(manager, id, existing.maxStackHeight + 1, newHeight);
|
||||
} else if (newHeight < existing.maxStackHeight) {
|
||||
await this.removeSlotsAbove(manager, id, newHeight, existing.code);
|
||||
}
|
||||
|
||||
await manager.getRepository(WarehouseZoneStack).update(id, {
|
||||
code,
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
row: dto.row?.trim() ?? existing.row,
|
||||
bay: dto.bay?.trim() ?? existing.bay,
|
||||
position: dto.position?.trim() ?? existing.position,
|
||||
maxStackHeight: newHeight,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a stack. Refused while anything stands in it — the boxes would
|
||||
* be left pointing at a position every layout query drops.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
await this.assertStackEmpty(id, 'deleted');
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseZoneSlot).softDelete({ stackId: id });
|
||||
await manager.getRepository(WarehouseZoneStack).softDelete(id);
|
||||
});
|
||||
|
||||
return { id: existing.id, deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set operator intent on one slot. OCCUPIED is not settable — it is derived
|
||||
* from the inventory sitting there — and a slot holding a box cannot be
|
||||
* blocked or switched off underneath it.
|
||||
*/
|
||||
async updateSlot(slotId: string, dto: UpdateWarehouseZoneSlotDto): Promise<WarehouseZoneSlot> {
|
||||
const slot = await this.slotsRepository.findById(slotId);
|
||||
if (!slot) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`);
|
||||
|
||||
const status = dto.status ?? slot.status;
|
||||
const isActive = dto.isActive ?? (dto.status ? dto.status !== 'INACTIVE' : slot.isActive);
|
||||
const closingOff = status === 'BLOCKED' || status === 'INACTIVE' || isActive === false;
|
||||
|
||||
if (closingOff) {
|
||||
const [held] = await this.dataSource.query(
|
||||
`SELECT i.id FROM freight.warehouse_inventory i
|
||||
WHERE i.slot_id = $1 AND i.deleted_at IS NULL
|
||||
AND i.status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
|
||||
LIMIT 1`,
|
||||
[slotId],
|
||||
);
|
||||
if (held) {
|
||||
throw new ConflictException('Slot still holds a container. Move it out first.');
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.slotsRepository.update(slotId, { status, isActive });
|
||||
if (!updated) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Occupancy of one stack, level by level. */
|
||||
async slotOccupancy(stackId: string): Promise<
|
||||
Array<{ slotId: string; level: number; effectiveStatus: string; inventoryId: string | null }>
|
||||
> {
|
||||
const stack = await this.findById(stackId);
|
||||
const layout = await this.placement.zoneLayout(stack.zoneId);
|
||||
const found = layout.stacks.find((s) => s.stackId === stackId);
|
||||
return (found?.slots ?? []).map((s) => ({
|
||||
slotId: s.slotId,
|
||||
level: s.level,
|
||||
effectiveStatus: s.effectiveStatus,
|
||||
inventoryId: s.inventoryId,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Idempotent: a level that already exists (e.g. after a height cut and re-raise) is skipped. */
|
||||
private async generateSlots(
|
||||
manager: EntityManager,
|
||||
stackId: string,
|
||||
fromLevel: number,
|
||||
toLevel: number,
|
||||
): Promise<void> {
|
||||
const repository = manager.getRepository(WarehouseZoneSlot);
|
||||
const existing = await repository.find({ where: { stackId }, withDeleted: true });
|
||||
const byLevel = new Map(existing.map((slot) => [slot.level, slot]));
|
||||
|
||||
for (let level = fromLevel; level <= toLevel; level += 1) {
|
||||
const found = byLevel.get(level);
|
||||
if (found?.deletedAt) {
|
||||
// Bring a previously trimmed level back rather than colliding with the
|
||||
// (stack_id, level) unique index.
|
||||
await repository.restore(found.id);
|
||||
await repository.update(found.id, { status: 'AVAILABLE', isActive: true });
|
||||
} else if (!found) {
|
||||
await repository.save(repository.create({ stackId, level, status: 'AVAILABLE', isActive: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSlotsAbove(
|
||||
manager: EntityManager,
|
||||
stackId: string,
|
||||
newHeight: number,
|
||||
stackCode: string,
|
||||
): Promise<void> {
|
||||
const occupied = await this.placement.occupiedLevels(stackId, null, manager);
|
||||
const stillUsed = occupied.filter((level) => level > newHeight);
|
||||
if (stillUsed.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Stack ${stackCode}: level(s) ${stillUsed.join(', ')} still hold containers — cannot lower the height to ${newHeight}`,
|
||||
);
|
||||
}
|
||||
|
||||
const doomed = await manager.getRepository(WarehouseZoneSlot).find({
|
||||
where: { stackId, deletedAt: IsNull() },
|
||||
});
|
||||
const ids = doomed.filter((slot) => slot.level > newHeight).map((slot) => slot.id);
|
||||
if (ids.length > 0) {
|
||||
await manager.getRepository(WarehouseZoneSlot).softDelete({ id: In(ids) });
|
||||
}
|
||||
}
|
||||
|
||||
private async assertStackEmpty(stackId: string, action: string): Promise<void> {
|
||||
const occupied = await this.placement.occupiedLevels(stackId);
|
||||
if (occupied.length > 0) {
|
||||
throw new ConflictException(
|
||||
`Stack still holds ${occupied.length} container(s) at level(s) ${occupied.join(', ')}. Move them out before it can be ${action}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCodeUnique(zoneId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.stacksRepository.findAll({ where: { zoneId, code } });
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Stack code ${code} already exists in this zone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
@@ -18,6 +18,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseZones.update,
|
||||
FREIGHT_PERMS.warehouseZones.delete,
|
||||
])
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
@@ -40,4 +41,41 @@ export class WarehouseZonesController {
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||
return this.zonesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/contents')
|
||||
@ApiOperation({
|
||||
summary: 'What is currently stored in a zone',
|
||||
description: 'A row per container — booked units and backlog-registered containers alike.',
|
||||
})
|
||||
contents(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.zonesService.contents(id);
|
||||
}
|
||||
|
||||
@Get(':id/layout')
|
||||
@ApiOperation({
|
||||
summary: 'Physical layout of a zone',
|
||||
description: 'Every ground stack with its levels, what stands on each, and the slot summary.',
|
||||
})
|
||||
layout(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.zonesService.layout(id);
|
||||
}
|
||||
|
||||
@Get(':id/slot-summary')
|
||||
@ApiOperation({
|
||||
summary: 'Configured capacity vs physical slots vs occupancy',
|
||||
description: 'Flags a zone whose built slots exceed its configured container capacity.',
|
||||
})
|
||||
slotSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.zonesService.slotSummary(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.delete)
|
||||
@ApiOperation({
|
||||
summary: 'Delete warehouse zone',
|
||||
description: 'Soft-deletes the zone. Refused while inventory still sits in it.',
|
||||
})
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.zonesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||
|
||||
/** One container (or one bulk lot) currently sitting in a zone. */
|
||||
export interface ZoneContentItem {
|
||||
inventoryId: string;
|
||||
containerNumber: string | null;
|
||||
unloadedAt: string | null;
|
||||
containerType: string | null;
|
||||
direction: 'IMPORT' | 'EXPORT' | null;
|
||||
loadState: string | null;
|
||||
status: string;
|
||||
bookingReference: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZonesService {
|
||||
constructor(
|
||||
private readonly zonesRepository: WarehouseZonesRepository,
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
private readonly placement: WarehousePlacementService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseZone[]> {
|
||||
@@ -98,6 +117,94 @@ export class WarehouseZonesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is physically sitting in one zone, a row per container.
|
||||
*
|
||||
* Container identity has two sources and neither covers the other: booked
|
||||
* cargo carries its units on `booking_container_units`, while a backlog
|
||||
* registration has no booking and links `warehouse_inventory.container_id`
|
||||
* straight to a `containers` row. Bulk cargo has neither, so it comes back
|
||||
* with a null container number rather than being dropped from its zone.
|
||||
*
|
||||
* Full/empty likewise: `containers.status` when there is a container row,
|
||||
* otherwise a returned unit is the empty one.
|
||||
*/
|
||||
async contents(zoneId: string): Promise<ZoneContentItem[]> {
|
||||
await this.findById(zoneId);
|
||||
|
||||
return this.dataSource.query(
|
||||
`SELECT i.id AS "inventoryId",
|
||||
COALESCE(c.container_number, bcu.container_number) AS "containerNumber",
|
||||
i.unloaded_at AS "unloadedAt",
|
||||
COALESCE(ct_direct.label, ct_booked.label, bc.container_size) AS "containerType",
|
||||
b.trade_direction AS "direction",
|
||||
CASE
|
||||
WHEN c.status IS NOT NULL THEN c.status
|
||||
WHEN bcu.is_return THEN 'EMPTY'
|
||||
WHEN bcu.container_number IS NOT NULL THEN 'FULL'
|
||||
ELSE NULL
|
||||
END AS "loadState",
|
||||
i.status AS "status",
|
||||
b.reference AS "bookingReference"
|
||||
FROM freight.warehouse_inventory i
|
||||
LEFT JOIN freight.containers c ON c.id = i.container_id AND c.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types ct_direct ON ct_direct.id = c.container_type_id
|
||||
LEFT JOIN freight.bookings b ON b.id = i.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container bc ON bc.booking_id = b.id AND bc.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types ct_booked ON ct_booked.id = bc.container_type_id
|
||||
LEFT JOIN freight.booking_container_units bcu
|
||||
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||
WHERE i.zone_id = $1 AND i.deleted_at IS NULL
|
||||
ORDER BY i.unloaded_at DESC NULLS LAST,
|
||||
COALESCE(c.container_number, bcu.container_number)`,
|
||||
[zoneId],
|
||||
);
|
||||
}
|
||||
|
||||
/** The zone's physical layout: every ground stack, every level, what stands there. */
|
||||
async layout(zoneId: string) {
|
||||
await this.findById(zoneId);
|
||||
return this.placement.zoneLayout(zoneId);
|
||||
}
|
||||
|
||||
/** Configured capacity vs slots actually built vs slots actually full. */
|
||||
async slotSummary(zoneId: string) {
|
||||
await this.findById(zoneId);
|
||||
return this.placement.slotSummary({ zoneId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a zone. Inventory points at a zone, so a zone still holding
|
||||
* stock is refused — soft-deleting it would leave those rows pointing at a
|
||||
* location every zone-joining query drops. Configured stacks block it for the
|
||||
* same reason: they would survive their parent and never be reachable again.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
const [, held] = await this.inventoryRepository.findAndCount({ where: { zoneId: id } });
|
||||
|
||||
if (held > 0) {
|
||||
throw new ConflictException(
|
||||
`Zone ${existing.code} still holds ${held} inventory item(s). Move them out first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const [stacks] = await this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.warehouse_zone_stacks
|
||||
WHERE zone_id = $1 AND deleted_at IS NULL`,
|
||||
[id],
|
||||
);
|
||||
if (Number(stacks?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
`Zone ${existing.code} still has ${stacks.count} configured stack(s). Delete them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.zonesRepository.softDelete(id);
|
||||
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
@@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
FREIGHT_PERMS.warehouses.create,
|
||||
FREIGHT_PERMS.warehouses.update,
|
||||
FREIGHT_PERMS.warehouses.delete,
|
||||
FREIGHT_PERMS.warehouseYards.view,
|
||||
FREIGHT_PERMS.warehouseYards.create,
|
||||
])
|
||||
@@ -79,6 +80,16 @@ export class WarehousesController {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.delete)
|
||||
@ApiOperation({
|
||||
summary: 'Delete warehouse',
|
||||
description: 'Soft-deletes the warehouse. Refused while it still has yards.',
|
||||
})
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.warehousesService.remove(id);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
|
||||
@@ -21,6 +21,8 @@ import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movem
|
||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
|
||||
import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
@@ -47,6 +49,11 @@ import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapte
|
||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||
import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository';
|
||||
import { WarehouseZoneStacksController } from './warehouse-zone-stacks.controller';
|
||||
import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository';
|
||||
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
|
||||
import { WarehouseZonesController } from './warehouse-zones.controller';
|
||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
@@ -60,6 +67,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
Warehouse,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
WarehouseZoneStack,
|
||||
WarehouseZoneSlot,
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryMovement,
|
||||
WarehouseActivityLog,
|
||||
@@ -83,6 +92,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehousesController,
|
||||
WarehouseYardsController,
|
||||
WarehouseZonesController,
|
||||
WarehouseZoneStacksController,
|
||||
WarehouseInventoryController,
|
||||
WarehouseLoadingsController,
|
||||
WarehouseInspectionController,
|
||||
@@ -93,6 +103,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehousesRepository,
|
||||
WarehouseYardsRepository,
|
||||
WarehouseZonesRepository,
|
||||
WarehouseZoneStacksRepository,
|
||||
WarehouseZoneSlotsRepository,
|
||||
WarehouseInventoryRepository,
|
||||
WarehouseInventoryMovementRepository,
|
||||
WarehouseActivityLogRepository,
|
||||
@@ -103,6 +115,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseZoneStacksService,
|
||||
WarehousePlacementService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseActivityLogService,
|
||||
WarehouseDashboardService,
|
||||
@@ -119,6 +133,8 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseZoneStacksService,
|
||||
WarehousePlacementService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
|
||||
@@ -54,6 +54,7 @@ export class WarehousesService {
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
freightType: dto.freightType ?? null,
|
||||
stationId: dto.stationId ?? null,
|
||||
facilityId: dto.facilityId ?? null,
|
||||
locationName: dto.locationName?.trim() ?? null,
|
||||
@@ -87,6 +88,7 @@ export class WarehousesService {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
freightType: dto.freightType ?? existing.freightType,
|
||||
stationId: dto.stationId ?? existing.stationId,
|
||||
facilityId: dto.facilityId ?? existing.facilityId,
|
||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||
@@ -108,6 +110,25 @@ export class WarehousesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a warehouse. Yards (and therefore zones and inventory, which
|
||||
* hang off a zone) are left alone — a warehouse holding them is refused
|
||||
* rather than silently orphaning stock.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (existing.yards?.length) {
|
||||
throw new ConflictException(
|
||||
`Warehouse ${existing.code} still has ${existing.yards.length} yard(s). Delete them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.warehousesRepository.softDelete(id);
|
||||
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
||||
private mapDbError(error: unknown): never {
|
||||
if (error instanceof QueryFailedError) {
|
||||
|
||||
Reference in New Issue
Block a user