diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d85903b40..d450ebfc8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts new file mode 100644 index 000000000..6009d9ea4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts @@ -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 { + 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 { + if (await queryRunner.hasColumn(this.table, 'unloaded_at')) { + await queryRunner.dropColumn(this.table, 'unloaded_at'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts index 9efa5c51a..8df9c0dd8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 64dbda1ec..1434c3653 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -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 = { + // 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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 9a4a6411d..c2f1ce6f5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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() { 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 00127c1b9..ad24a8b31 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 @@ -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 { + 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. diff --git a/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts new file mode 100644 index 000000000..c3a98c224 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts @@ -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 { + 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)}`, + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index 7322c2cde..27ad0ebc6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -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(null); const [moveItem, setMoveItem] = useState(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} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 42fd23cfd..760eacce7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -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(null); const [busyId, setBusyId] = useState(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 @@ -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 ( + + + {items.length} unloaded item{items.length !== 1 ? 's' : ''} + + + toast({ + title: 'Last mile delivery', + description: `Door delivery for ${it.booking?.reference ?? it.id} — handled in the next batch.`, + }) + } + /> + + ); +} + /** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); @@ -897,12 +917,10 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal - + - - Unloaded Queue — coming in the next batch. - + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index fb9c3047f..7156cf585 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -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; 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({ )} + {onLastMile && item.booking?.lastMileDeliveryAddress && ( + + onLastMile(item)}> + + + + )} onHistory(item)}> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index 9127881c9..f3b8a55d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { } const inventoryStatusColor: Record = { + UNLOADED: 'indigo', RECEIVED: 'yellow', STORED: 'blue', RESERVED: 'grape', diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index e8f176e7b..00bc08c34 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 6511dd522..ba44295a5 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -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) { diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 8befdcdd2..e223a1734 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -39,6 +39,7 @@ import type { BulkDispatchResult, ImportTrain, ImportTrainItem, + AutoUnloadArrivedResult, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -146,6 +147,11 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE), importTrainItems: (scheduleId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)), + autoUnloadArrivedBookings: (scheduleId: string) => + apiClient.post( + URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED, + { scheduleId }, + ), move: (id: string, payload: MoveInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload), movements: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index f9ce8c588..16dd29b45 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -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 = { + 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;