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

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backlog registration of full containers that were already sitting in a yard
* before the system knew about them. Such a row carries a true, backdated
* `arrived_at` for the record but accrues NO storage or demurrage — the
* operator decided these are not billable retroactively — so the flag exists
* to keep the fee engine off them.
*
* `company_id` / `company_name` carry the owner, since a backlog row has no
* booking to inherit one from. The name is free text for a company that is not
* a registered customer yet.
*/
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
name = 'WarehouseInventoryBacklogRegistration3800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS backlog_registration,
DROP COLUMN IF EXISTS company_id,
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

@@ -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();
});
});

View File

@@ -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[];
}

View File

@@ -105,6 +105,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;

View File

@@ -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,

View File

@@ -16,6 +16,10 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.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 +147,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' })

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

View File

@@ -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' })

View File

@@ -108,6 +108,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) {