Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts
Hagernesh a6ea1a48ac feat(warehouses): track physical container stack and slot positions
Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.

- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
  stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
  unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
  accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
2026-08-28 16:09:41 +00:00

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