diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 64ccb1929..86b9dedc6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f5e00f503..88a2175b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 0592dafc4..b1dabfa16 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts index 85b6d0878..e95cb4fe8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index a2313a8df..aa032c17d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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) { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index eaa399f03..86641c7be 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent) => { interface UnitErrors { containerNumber?: string; + sealNumber?: string; vgmTons?: string; } @@ -886,6 +887,9 @@ export default function GlCreateBookingForm() { } else if ((numberCounts.get(key) ?? 0) > 1) { errs.containerNumber = "Duplicate container number in this shipment."; } + if (u.sealNumber.trim() === "") { + errs.sealNumber = "Seal number is required."; + } const vgm = Number(u.vgmTons); if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) { errs.vgmTons = "Enter a valid VGM."; @@ -1185,7 +1189,7 @@ export default function GlCreateBookingForm() { : {}), units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + sealNumber: u.sealNumber.trim(), vgmTons: Number(u.vgmTons) || 0, // Per-container handling — the server rolls these into the line // counts and bills each surcharge on the ticked containers only. @@ -1247,7 +1251,7 @@ export default function GlCreateBookingForm() { reeferQuantity: Number(l.reeferQuantity || 0) || undefined, units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + sealNumber: u.sealNumber.trim(), vgmTons: Number(u.vgmTons) || 0, isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), @@ -1857,7 +1861,7 @@ export default function GlCreateBookingForm() { Container number * - Seal number + Seal number * VGM (tons) * @@ -1901,8 +1905,13 @@ export default function GlCreateBookingForm() { style={{ flex: 1 }} /> patchUnit(lineIdx, unitIdx, { sealNumber: e.currentTarget.value, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts index c00091bfc..fd34bd967 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -151,6 +151,11 @@ export async function parseContainerExcel( numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1); } + const sealNumber = cell("sealNumber"); + if (!sealNumber) { + errors.push(`Row ${rowNo}: seal number is required.`); + } + const vgmRaw = cell("vgmTons"); const vgm = Number(vgmRaw); if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) { @@ -160,7 +165,7 @@ export async function parseContainerExcel( rows.push({ containerSize: size ?? "", containerNumber, - sealNumber: cell("sealNumber"), + sealNumber, vgmTons: vgmRaw, hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), reefer: opts.includeReefer && parseFlag(cell("reefer")), diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx index 1ec596ffe..b7de47ef9 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core'; +import { Alert, Button, Group, List, Modal, Select, Stack, Textarea, Text } from '@mantine/core'; +import { Layers } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -7,6 +8,7 @@ import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; +import { SlotPicker } from './SlotPicker'; interface MoveInventoryModalProps { opened: boolean; @@ -20,6 +22,7 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal const [warehouseId, setWarehouseId] = useState(''); const [yardId, setYardId] = useState(''); const [zoneId, setZoneId] = useState(''); + const [slotId, setSlotId] = useState(''); const [remarks, setRemarks] = useState(''); useEffect(() => { @@ -27,10 +30,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal setWarehouseId(''); setYardId(''); setZoneId(''); + setSlotId(''); setRemarks(''); } }, [opened]); + // A container with boxes stacked on top of it cannot be lifted out — the API + // refuses the move, so the button says why instead of firing a 409. + const accessibilityQuery = useQuery( + api.warehouses.containerAccessibility.queryOptions({ + input: { id: item?.id ?? '' }, + enabled: opened && Boolean(item?.id), + }), + ); + const accessibility = accessibilityQuery.data; + const blocked = accessibility ? !accessibility.accessible : false; + const warehousesQuery = useQuery( api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), ); @@ -69,7 +84,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal try { await moveMutation.mutateAsync({ id: item.id, - payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined }, + payload: { + warehouseId, + yardId, + zoneId, + slotId: slotId || undefined, + remarks: remarks.trim() || undefined, + }, }); toast({ title: 'Inventory moved' }); onClose(); @@ -81,6 +102,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal return ( + {blocked && accessibility ? ( + } color="orange" variant="light" title="Container is buried"> + + It sits at {accessibility.stackCode} level {accessibility.level} with{' '} + {accessibility.blockingContainers.length} container(s) stacked on top. Move these out + first: + + + {accessibility.blockingContainers.map((b) => ( + + {b.containerNumber ?? 'Container'} — level {b.level} + + ))} + + + ) : null}