feat(warehouse): Batch 8 — Import Auto Unload Arrived Bookings (→ UNLOADED)

- New UNLOADED inventory status (train-arrival landing state) + transitions + unloaded_at column
  (idempotent migration 1791000000003) + INVENTORY_UNLOADED activity type
- POST /warehouse-inventory/import/auto-unload-arrived-bookings { scheduleId }: validates ARRIVED
  IMPORT train, unloads all eligible assigned bookings (IN_TRANSIT/ARRIVED_AT_*) into UNLOADED,
  records unloadedAt + activity. Does NOT store and does NOT inspect. Reuses allocation + inventory
  plumbing. Returns { unloadedCount, skippedCount, failedCount, results }.
- Arrive Queue "Auto Unload Arrived Bookings" button now calls the new endpoint (was per-booking loop)
- Import → Unloaded Queue tab: lists UNLOADED items via InventoryWorkbench (existing actions preserved:
  Inspect/Store/Move/History) + a Last Mile action shown ONLY when booking requested door delivery
- Batch8TestDataSeeder: sets seed import train bookings to IN_TRANSIT (unload-eligible)
- Verified: auto-unload → 1 UNLOADED (unloadedAt set, not stored, not inspected); ineligible skipped;
  idempotent re-run skips already-unloaded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-21 12:57:29 +00:00
parent 35c3341baf
commit 8b28a7c430
15 changed files with 339 additions and 32 deletions

View File

@@ -51,6 +51,7 @@ import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -135,6 +136,7 @@ import { OverviewModule } from './modules/overview/overview.module';
Batch14TestDataSeeder,
Batch5TestDataSeeder,
Batch7TestDataSeeder,
Batch8TestDataSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -150,6 +152,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -167,6 +170,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch14TestDataSeeder.run();
await this.batch5TestDataSeeder.run();
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
await this.demoFreightDataSeeder.run();

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Batch 8 — train-arrival unload landing state on warehouse_inventory:
* - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection)
*
* The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change.
* Idempotent: the shared dev DB may already carry this column (added by another checkout).
*/
export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface {
private readonly table = 'freight.warehouse_inventory';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasColumn(this.table, 'unloaded_at')) {
await queryRunner.dropColumn(this.table, 'unloaded_at');
}
}
}

View File

@@ -3,6 +3,7 @@ import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_ACTIVITY_TYPES = [
'INVENTORY_RECEIVED',
'INVENTORY_UNLOADED',
'INVENTORY_STORED',
'INVENTORY_MOVED',
'INVENTORY_RESERVED',

View File

@@ -14,6 +14,7 @@ import { WarehouseZone } from './warehouse-zone.entity';
// 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',
'RECEIVED',
'STORED',
'RESERVED',
@@ -27,6 +28,9 @@ export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[num
/** 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'],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
STORED: ['RESERVED'],
RESERVED: ['READY_FOR_LOADING'],
@@ -111,6 +115,10 @@ export class WarehouseInventory extends BaseEntity {
@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;

View File

@@ -130,6 +130,12 @@ export class WarehouseInventoryController {
return this.scheduling.importTrainDetail(scheduleId);
}
@Post('import/auto-unload-arrived-bookings')
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
}
@Get('loadable-wagons')
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {

View File

@@ -175,6 +175,13 @@ export interface BulkDispatchResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -714,6 +721,154 @@ export class WarehouseInventoryService {
return result;
}
/** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */
private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [
'IN_TRANSIT',
'ARRIVED_AT_INDODE',
'ARRIVED_AT_DESTINATION',
'ARRIVED_AT_FACILITY',
];
/**
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
* items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue.
*/
async autoUnloadArrivedBookings(
scheduleId: string,
performedBy?: string,
): Promise<AutoUnloadArrivedResult> {
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
const [schedule] = await this.dataSource.query(
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'ARRIVED') {
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
}
const direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
if (direction !== 'IMPORT') {
throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`);
}
// 2. Assigned bookings on this train.
const bookings: {
id: string;
status: string;
weight: string | null;
freightType: string | null;
tradeDirection: string | null;
cargoTypeCode: string | null;
}[] = await this.dataSource.query(
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
[scheduleId],
);
const fallback = await this.pickDefaultLocation();
const now = new Date();
for (const booking of bookings) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason });
};
const fail = (reason: string) => {
result.failedCount += 1;
result.results.push({ bookingId: booking.id, status: 'FAILED', reason });
};
if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) {
skip(`Booking status ${booking.status} is not unload-eligible`);
continue;
}
try {
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
// Already unloaded or further along — leave it (do not regress the lifecycle).
if (existing && existing.status !== 'RECEIVED') {
skip(`Inventory already ${existing.status}`);
continue;
}
if (existing) {
await this.inventoryRepository.update(existing.id, {
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: existing.id,
warehouseId: existing.warehouseId,
description: 'Unloaded from arrived import train',
performedBy,
});
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
continue;
}
// No inventory yet — create it at the allocated (or default) location, in UNLOADED state.
const allocated = await this.allocation.resolveLocation({
freightType: booking.freightType,
tradeDirection: booking.tradeDirection,
cargoTypeCode: booking.cargoTypeCode,
});
const location = allocated ?? fallback;
if (!location) {
fail('No warehouse/yard/zone configured');
continue;
}
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
bookingId: booking.id,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: saved.id,
warehouseId: saved.warehouseId,
description: 'Unloaded from arrived import train',
performedBy,
});
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
return result;
}
/**
* Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal
* report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
/**
* Makes the Batch 7 seed import train demonstrable for Batch 8: a booking riding an ARRIVED
* train is IN_TRANSIT until unloaded, so flip the seed import train's assigned bookings to
* IN_TRANSIT (an unload-eligible status). Idempotent — re-applying IN_TRANSIT is a no-op.
*/
@Injectable()
export class Batch8TestDataSeeder {
private readonly logger = new Logger(Batch8TestDataSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
try {
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const bookingRepo = this.dataSource.getRepository(Booking);
const train = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } });
if (!train) {
this.logger.log('SEED-IMP-TRAIN-01 not found; skipping Batch 8 seed');
return;
}
const links = await scheduleBookingRepo.find({ where: { trainScheduleId: train.id } });
let updated = 0;
for (const link of links) {
const booking = await bookingRepo.findOne({ where: { id: link.bookingId } });
if (!booking || booking.status === 'IN_TRANSIT') continue;
await bookingRepo.update(booking.id, { status: 'IN_TRANSIT' });
updated += 1;
}
if (updated > 0) {
this.logger.log(`✅ Batch 8: set ${updated} import train booking(s) to IN_TRANSIT (unload-eligible)`);
} else {
this.logger.log('Batch 8: import train bookings already IN_TRANSIT, skipping');
}
} catch (error) {
this.logger.error(
`Batch8TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}

View File

@@ -25,10 +25,12 @@ import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
@@ -152,6 +154,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}

View File

@@ -20,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lu
import { useToast } from '@/hooks/use-toast';
import {
useAutoUnloadArrivedBookings,
useBulkDispatchExport,
useBulkReceive,
useEligibleBookings,
@@ -29,12 +30,13 @@ import {
useLoadedExport,
useReadyToLoadExport,
useReceiveInventory,
useUnloadBooking,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type {
AutoUnloadArrivedResult,
BulkDispatchResult,
BulkReceiveResult,
ImportTrain,
@@ -43,8 +45,8 @@ import type {
ReadyToLoadRow,
ReceiveInventoryPayload,
} from '@/types/warehouse';
import { warehouseService } from '@/services/warehouse.service';
import { BookingSelect } from './BookingSelect';
import { InventoryWorkbench } from './InventoryWorkbench';
import { extractErrorMessage, formatDate, formatNumber } from './options';
interface ReceiveInventoryModalProps {
@@ -721,44 +723,34 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
function ImportArriveQueueTab({
location,
enabled,
onChanged,
}: {
location: Location;
enabled: boolean;
onChanged?: () => void;
}) {
const { toast } = useToast();
const { data: trains = [], isLoading } = useImportArriveQueue(enabled);
const unloadBooking = useUnloadBooking();
const autoUnloadMutation = useAutoUnloadArrivedBookings();
const [openId, setOpenId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const locationPayload = location.warehouseId
? { warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId }
: undefined;
const autoUnload = async (train: ImportTrain) => {
setBusyId(train.scheduleId);
try {
const items = (await warehouseService.importTrainItems(train.scheduleId)).data;
if (items.length === 0) {
toast({ title: 'No bookings to unload on this train' });
return;
}
let ok = 0;
for (const it of items) {
try {
await unloadBooking.mutateAsync({ bookingId: it.bookingId, payload: locationPayload });
ok += 1;
} catch {
/* already unloaded / not eligible — skip */
}
}
const res = (await autoUnloadMutation.mutateAsync(train.scheduleId)) as {
data: AutoUnloadArrivedResult;
};
const r = res.data;
const extra = [
r.skippedCount ? `${r.skippedCount} skipped` : '',
r.failedCount ? `${r.failedCount} failed` : '',
]
.filter(Boolean)
.join(', ');
toast({
title: `Auto-unloaded ${ok}/${items.length} booking(s)`,
description: ok < items.length ? `${items.length - ok} skipped (already unloaded or not eligible)` : undefined,
title: `${r.unloadedCount} unloaded`,
description: extra || undefined,
});
onChanged?.();
} catch (error) {
@@ -839,7 +831,7 @@ function ImportArriveQueueTab({
loading={busyId === t.scheduleId}
onClick={() => autoUnload(t)}
>
Auto Unload
Auto Unload Arrived Bookings
</Button>
</Group>
</Table.Td>
@@ -862,6 +854,34 @@ function ImportArriveQueueTab({
);
}
/**
* Import Unloaded Queue: items unloaded off arrived trains (status UNLOADED), with the full set of
* lifecycle actions (Inspect / Store / Move / History / …) plus a Last Mile action shown only when
* the booking requested door delivery. No automatic storage happens here — the operator drives it.
*/
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const { data: items = [], isLoading } = useWarehouseInventory(enabled ? { status: 'UNLOADED' } : undefined);
return (
<Stack gap="sm" mt="sm">
<Text size="sm" c="dimmed">
<b>{items.length}</b> unloaded item{items.length !== 1 ? 's' : ''}
</Text>
<InventoryWorkbench
items={items}
isLoading={isLoading}
onLastMile={(it) =>
toast({
title: 'Last mile delivery',
description: `Door delivery for ${it.booking?.reference ?? it.id} — handled in the next batch.`,
})
}
/>
</Stack>
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
@@ -897,12 +917,10 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
</Tabs.List>
<Tabs.Panel value="arrive-queue">
<ImportArriveQueueTab location={location} enabled={opened} onChanged={onReceived} />
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="unloaded-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">
Unloaded Queue coming in the next batch.
</Text>
<ImportUnloadedQueueTab enabled={opened} />
</Tabs.Panel>
<Tabs.Panel value="dispatch-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, Coins, History, MapPin } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
@@ -14,6 +14,8 @@ interface WarehouseInventoryTableProps {
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
@@ -48,6 +50,7 @@ export function WarehouseInventoryTable({
onHistory,
onInspect,
onFeePreview,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
@@ -171,6 +174,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
<MapPin size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />

View File

@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',

View File

@@ -320,6 +320,7 @@ export const URL_CONSTANTS = {
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/import/trains/${scheduleId}/items`,
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
},
WAREHOUSE_LOADINGS: {

View File

@@ -245,6 +245,10 @@ export function useImportTrainItems(scheduleId?: string) {
});
}
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
export const useAutoUnloadArrivedBookings = () =>
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {

View File

@@ -39,6 +39,7 @@ import type {
BulkDispatchResult,
ImportTrain,
ImportTrainItem,
AutoUnloadArrivedResult,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -146,6 +147,11 @@ export const warehouseService = {
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
importTrainItems: (scheduleId: string) =>
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
autoUnloadArrivedBookings: (scheduleId: string) =>
apiClient.post<AutoUnloadArrivedResult>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
{ scheduleId },
),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>

View File

@@ -23,6 +23,7 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
@@ -52,6 +53,7 @@ export type InventoryAction =
* {@link getNextInventoryAction} which resolves those at runtime.
*/
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
UNLOADED: 'store',
RECEIVED: 'store',
STORED: 'reserve',
RESERVED: 'ready-for-loading',
@@ -72,6 +74,7 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
const isImport = item.booking?.tradeDirection === 'IMPORT';
switch (item.status) {
case 'UNLOADED':
case 'RECEIVED':
// Import goods skip storage; they need inspection before pickup.
if (isImport) return inspected ? 'ready-for-pickup' : null;
@@ -206,6 +209,8 @@ export interface InventoryBookingRef {
status?: string | null;
paymentStatus?: string | null;
tradeDirection?: string | null;
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
lastMileDeliveryAddress?: string | null;
}
export interface InventoryMovement {
@@ -414,6 +419,13 @@ export interface ImportTrain {
status: string;
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface ImportTrainItem {
bookingId: string;
bookingReference: string | null;