mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Extends the warehouse hierarchy below zone with ground stacks and vertical slots, so a container's exact position is recorded rather than only its zone. - freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable stack_id / slot_id on warehouse_inventory (existing rows stay valid) - slot occupancy is derived from inventory status, guarded by a partial unique index, so no exit path has to remember to free a slot - placement service: hierarchy validation, bottom-up stacking rules, accessibility/blocking-container reads, capacity vs slot summaries - stack CRUD with auto-generated slots; reuses warehouse-zone permissions - slot support folded into the existing move()/store() paths - fix: validateLocation now rejects a mismatched warehouse/yard/zone triple - seed:warehouse-layout builds the layout from a JSON config
246 lines
9.5 KiB
TypeScript
246 lines
9.5 KiB
TypeScript
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`);
|
|
}
|
|
}
|
|
}
|