feat(warehouses): allow deleting a warehouse

Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
This commit is contained in:
Hagernesh
2026-08-28 14:39:34 +00:00
parent 224d46402d
commit 8ef50f9aff
21 changed files with 1264 additions and 7 deletions

View File

@@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import {
BulkRegisterBacklogDto,
RegisterBacklogContainerDto,
} from './dto/register-backlog.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -2863,6 +2867,136 @@ export class WarehouseInventoryService {
await this.lastMileService.acceptBooking(booking.reference);
}
/**
* Register a loaded container that is already physically in a yard but was
* never entered in the system. Unlike receive(), there is no booking, no
* truck entrance to record (nobody remembers the driver of a box that has sat
* for months) and the arrival is backdated to when it actually turned up.
*
* The row is flagged `backlogRegistration`, which keeps the fee engine off it
* entirely — see WarehouseFeeService.previewForInventory. Capacity is still
* charged, because the box does occupy the yard.
*/
async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise<WarehouseInventory> {
const id = await this.dataSource.transaction((manager) => this.saveBacklogContainer(manager, dto));
const saved = await this.inventoryRepository.findById(id);
if (!saved) throw new NotFoundException(`Inventory ${id} not found after registration`);
return saved;
}
/** The write itself, so single and bulk share one transaction each. */
private async saveBacklogContainer(
manager: EntityManager,
dto: RegisterBacklogContainerDto,
): Promise<string> {
const containerNumber = dto.containerNumber.trim().toUpperCase();
const arrivedAt = new Date(dto.arrivedAt);
if (Number.isNaN(arrivedAt.getTime())) {
throw new BadRequestException(`Arrival date "${dto.arrivedAt}" is not a valid date`);
}
if (arrivedAt.getTime() > Date.now()) {
throw new BadRequestException('Arrival date cannot be in the future');
}
{
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
const containerType = await manager.query(
`SELECT id FROM freight.container_types WHERE id = $1 AND deleted_at IS NULL`,
[dto.containerTypeId],
);
if (containerType.length === 0) {
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
}
// container_number is UNIQUE — reuse the existing record rather than
// colliding, so a box seen before keeps one identity.
const containers = manager.getRepository(Container);
let container = await containers.findOne({ where: { containerNumber } });
if (container) {
const alreadyHeld = await manager.getRepository(WarehouseInventory).findOne({
where: { containerId: container.id, status: In(['RECEIVED', 'STORED', 'READY_FOR_PICKUP']) },
});
if (alreadyHeld) {
throw new BadRequestException(
`Container ${containerNumber} is already in the warehouse (status ${alreadyHeld.status})`,
);
}
} else {
container = await containers.save(
containers.create({
containerNumber,
containerTypeId: dto.containerTypeId,
sealNumber: dto.sealNumber?.trim() || null,
tareWeight: dto.tareWeight ?? 0,
maxGrossWeight: dto.maxGrossWeight ?? 0,
status: 'LOADED',
bookingId: null,
}),
);
}
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
this.assertCapacity('Warehouse', warehouse, weight, volume, 1);
this.assertCapacity('Yard', yard, weight, volume, 1);
this.assertCapacity('Zone', zone, weight, volume, 1);
const owner = dto.companyName?.trim() || null;
const grnNumber = this.generateGrnNumber('WH', 'BACKLOG', arrivedAt, owner);
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId: null,
containerId: container.id,
companyId: dto.companyId ?? null,
companyName: owner,
quantity: 1,
weight,
volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED',
arrivedAt,
backlogRegistration: true,
notes: this.buildReceiveNote({
grnNumber,
notes:
dto.notes?.trim() ||
`Backlog registration — already in yard, arrived ${arrivedAt.toISOString().slice(0, 10)}`,
}),
}),
);
await this.applyCapacityDelta(manager, dto, weight, volume, 1);
return saved.id;
}
}
/**
* Bulk backlog registration. All-or-nothing: one bad row rejects the sheet,
* so a half-registered yard can never happen.
*/
async bulkRegisterBacklogContainers(dto: BulkRegisterBacklogDto): Promise<WarehouseInventory[]> {
const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase());
const seen = new Set<string>();
const repeated = [...new Set(numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))];
if (repeated.length > 0) {
throw new BadRequestException(`Container number(s) repeated in the upload: ${repeated.join(', ')}`);
}
const ids = await this.dataSource.transaction(async (manager) => {
const written: string[] = [];
for (const container of dto.containers) {
written.push(await this.saveBacklogContainer(manager, container));
}
return written;
});
return this.inventoryRepository.findAll({ where: { id: In(ids) } });
}
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);