feat(warehouses): backoffice UI for container stack positions

Surfaces the physical stack/slot model in the staff app.

- ZoneLayoutModal: stacks drawn level by level with occupancy colours,
  configured-vs-built-vs-occupied counts, and stack create/delete plus
  block/reserve/free on empty levels
- SlotPicker in the store and move modals, offering only the next
  fillable level of each stack so the form cannot suggest a position
  the API will refuse
- move modal warns when a container is buried, lists the blockers, and
  disables the action instead of firing a 409
- fix: move() now asserts accessibility server-side, matching release —
  both are exits from a stack
This commit is contained in:
Hagernesh
2026-08-29 06:37:53 +00:00
parent abfd55d43e
commit 312c1f1da3
22 changed files with 1074 additions and 27 deletions

View File

@@ -1875,9 +1875,12 @@ export class BookingWagonCancellationService {
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
// A credit snapshot taken before seals were mandatory can carry
// none; the booking service normalizes the blank back to null
// rather than blocking the rebook of already-paid cargo.
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
? (replacement[i]?.sealNumber ?? '')
: (u.sealNumber ?? ''),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,

View File

@@ -2250,7 +2250,9 @@ export class ContractBookingService {
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber,
sealNumber: unit.sealNumber ?? null,
// Legacy units recovered by the remainder placement can still
// arrive sealless — keep those null rather than empty-string.
sealNumber: unit.sealNumber?.trim() || null,
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,

View File

@@ -7,6 +7,7 @@ import {
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
})
containerNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
@IsString()
sealNumber?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsNotEmpty({ message: 'sealNumber is required' })
@MaxLength(64)
sealNumber!: string;
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
@IsNumber()

View File

@@ -333,7 +333,9 @@ export class RemainderPlacementService {
return deferred.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
// Deferred units predate the seal requirement; the booking service
// normalizes the blank back to null rather than rejecting the re-book.
sealNumber: u.sealNumber ?? '',
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,

View File

@@ -3140,6 +3140,13 @@ export class WarehouseInventoryService {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
// Taking a box out of a stack is physically impossible while others stand
// on top of it — the same rule the release path enforces. Moving is one of
// those exits, so it is checked here rather than only at release.
if (item.slotId) {
await this.placement.assertAccessible(item.id, manager);
}
// A move that names a slot is validated against the hierarchy it claims;
// one that does not clears the old slot, because the box has left it.
if (dto.slotId) {