mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
feat(warehouses): delete yards/zones and inspect zone contents
Yard and zone soft-delete, refused with 409 while a yard still has zones or a zone still holds inventory. warehouse_zones:delete was missing from the catalog — the role presets spread every zone key, so its absence crashes FreightPositionsSeeder at boot; a migration seeds it everywhere. Clicking a zone opens its contents as a datatable. Container identity comes from booking_container_units for booked cargo and from containers for backlog registrations; bulk cargo keeps its row with no container number rather than disappearing from the zone.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
|
||||
* warehouse and yard delete permissions, which already exist.
|
||||
*
|
||||
* `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
|
||||
* into the warehouse positions, so the moment the key is added to the registry
|
||||
* `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
|
||||
* at boot — and throws `missing_permissions:<key>` if the row is absent. The
|
||||
* catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
|
||||
* `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
|
||||
*
|
||||
* Idempotent on `key`; keeps the registry's fixed uuid so every environment
|
||||
* lands on the same id. Skips silently when the freight application row is
|
||||
* absent, since there is nothing to attach to.
|
||||
*/
|
||||
export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
|
||||
private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
|
||||
private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT $2::uuid,
|
||||
$1::varchar,
|
||||
'{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants go first, or the delete trips the position/role permission foreign
|
||||
* keys — a half-removed permission is worse than one left in place.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[WarehouseZoneDeletePermission3810000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[WarehouseZoneDeletePermission3810000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
WarehouseZoneDeletePermission3810000000000.KEY,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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) {
|
||||
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;
|
||||
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('404s on an unknown zone', async () => {
|
||||
const { service } = makeZonesService(null, 0);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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,23 @@ 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);
|
||||
}
|
||||
|
||||
@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,33 @@
|
||||
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 { 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,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseZone[]> {
|
||||
@@ -98,6 +115,70 @@ 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],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
|
||||
@@ -1272,6 +1272,11 @@ export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:warehouse_zones:update",
|
||||
"Update warehouse zone",
|
||||
),
|
||||
perm(
|
||||
"f1c00001-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:warehouse_zones:delete",
|
||||
"Delete warehouse zone",
|
||||
),
|
||||
perm(
|
||||
"f1d00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:warehouse_allocation_rules:view",
|
||||
@@ -2307,6 +2312,7 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:warehouse_zones:view",
|
||||
create: "edr_freight_app:warehouse_zones:create",
|
||||
update: "edr_freight_app:warehouse_zones:update",
|
||||
delete: "edr_freight_app:warehouse_zones:delete",
|
||||
},
|
||||
warehouseAllocationRules: {
|
||||
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||
|
||||
Reference in New Issue
Block a user