Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts
Hagernesh a6ea1a48ac feat(warehouses): track physical container stack and slot positions
Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.

- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
  stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
  unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
  accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
2026-08-28 16:09:41 +00:00

235 lines
8.7 KiB
TypeScript

import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Cargo } from '../../cargoes/entities/cargoes.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Warehouse } from './warehouse.entity';
import { WarehouseYard } from './warehouse-yard.entity';
import { WarehouseZone } from './warehouse-zone.entity';
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
// Lifecycle. Supersedes the Batch 1 set
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction:
// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
export const WAREHOUSE_INVENTORY_STATUSES = [
'UNLOADED',
'UNLOADED_AT_DJIBOUTI_PORT',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
'READY_FOR_PICKUP',
'DELIVERED',
] as const;
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
/** Allowed forward transitions for the inventory lifecycle. */
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
UNLOADED_AT_DJIBOUTI_PORT: [],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
// Reserve is retired from the operator flow — a stored export item advances
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
// READY_FOR_PICKUP is the way back out for an IMPORT item that was parked in
// storage from READY_FOR_PICKUP; without it, Store is a one-way door.
STORED: ['RESERVED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: ['UNLOADED_AT_DJIBOUTI_PORT'],
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
// it / customs or inspection hold / operator chooses to store.
READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'],
DELIVERED: [],
};
/**
* Statuses in which an inventory row is still physically standing in its slot.
* The moment it is LOADED onto a train, dispatched, or handed over, the ground
* is free again — so occupancy is read from this list rather than written to
* the slot row. The partial unique index in
* `WarehouseZoneStacksSlots3830000000000` uses exactly the same list; change
* one and you must change the other.
*/
export const SLOT_OCCUPYING_STATUSES: readonly WarehouseInventoryStatus[] = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'READY_FOR_PICKUP',
];
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@Index(['warehouseId'])
@Index(['yardId'])
@Index(['zoneId'])
@Index(['bookingId'])
@Index(['cargoId'])
@Index(['containerId'])
@Index(['goodsId'])
@Index(['stackId'])
@Index(['slotId'])
@Index(['status'])
export class WarehouseInventory extends BaseEntity {
@Column({ name: 'warehouse_id', type: 'uuid' })
warehouseId!: string;
@ManyToOne(() => Warehouse)
@JoinColumn({ name: 'warehouse_id' })
warehouse?: Warehouse;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => WarehouseYard)
@JoinColumn({ name: 'yard_id' })
yard?: WarehouseYard;
@Column({ name: 'zone_id', type: 'uuid' })
zoneId!: string;
@ManyToOne(() => WarehouseZone)
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
/**
* Exact physical position inside the zone. Nullable and additive: every row
* that predates the stack/slot model, and every non-container yard, keeps
* working with zone-level placement alone.
*/
@Column({ name: 'stack_id', type: 'uuid', nullable: true })
stackId?: string | null;
@ManyToOne(() => WarehouseZoneStack, { nullable: true })
@JoinColumn({ name: 'stack_id' })
stack?: WarehouseZoneStack | null;
@Column({ name: 'slot_id', type: 'uuid', nullable: true })
slotId?: string | null;
@ManyToOne(() => WarehouseZoneSlot, { nullable: true })
@JoinColumn({ name: 'slot_id' })
slot?: WarehouseZoneSlot | null;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
cargoId?: string | null;
@ManyToOne(() => Cargo, { nullable: true })
@JoinColumn({ name: 'cargo_id' })
cargo?: Cargo | null;
@Column({ name: 'container_id', type: 'uuid', nullable: true })
containerId?: string | null;
@ManyToOne(() => Container, { nullable: true })
@JoinColumn({ name: 'container_id' })
container?: Container | null;
@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;
@Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
weight!: number;
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null;
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;
// Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected.
@Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true })
inspectionStatus?: string | null;
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
// Batch 8: when the goods were unloaded off the arrived train (before storage/inspection).
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
unloadedAt?: Date | null;
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
storedAt?: Date | null;
@Column({ name: 'reserved_at', type: 'timestamptz', nullable: true })
reservedAt?: Date | null;
@Column({ name: 'inspected_at', type: 'timestamptz', nullable: true })
inspectedAt?: Date | null;
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
readyForLoadingAt?: Date | null;
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
dispatchedAt?: Date | null;
// Batch 5 — demurrage / storage lifecycle timestamps.
@Column({ name: 'inspection_started_at', type: 'timestamptz', nullable: true })
inspectionStartedAt?: Date | null;
@Column({ name: 'inspection_completed_at', type: 'timestamptz', nullable: true })
inspectionCompletedAt?: Date | null;
@Column({ name: 'ready_for_pickup_at', type: 'timestamptz', nullable: true })
readyForPickupAt?: Date | null;
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
releaseDate?: Date | null;
// Import branch: reference of the DO / release order sent to the customer.
@Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true })
releaseOrderReference?: string | null;
// Import branch: when the goods were handed over to the customer (proof of delivery).
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
gateClearedAt?: Date | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}