mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -51,6 +51,7 @@ import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
|||||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||||
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||||
import { Batch7TestDataSeeder } from "./seed/batch7-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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
@@ -135,6 +136,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
Batch14TestDataSeeder,
|
Batch14TestDataSeeder,
|
||||||
Batch5TestDataSeeder,
|
Batch5TestDataSeeder,
|
||||||
Batch7TestDataSeeder,
|
Batch7TestDataSeeder,
|
||||||
|
Batch8TestDataSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -150,6 +152,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||||
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||||
|
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
) { }
|
) { }
|
||||||
@@ -167,6 +170,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.batch14TestDataSeeder.run();
|
await this.batch14TestDataSeeder.run();
|
||||||
await this.batch5TestDataSeeder.run();
|
await this.batch5TestDataSeeder.run();
|
||||||
await this.batch7TestDataSeeder.run();
|
await this.batch7TestDataSeeder.run();
|
||||||
|
await this.batch8TestDataSeeder.run();
|
||||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
// 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.
|
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||||
await this.demoFreightDataSeeder.run();
|
await this.demoFreightDataSeeder.run();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Column, Entity, Index } from 'typeorm';
|
|||||||
|
|
||||||
export const WAREHOUSE_ACTIVITY_TYPES = [
|
export const WAREHOUSE_ACTIVITY_TYPES = [
|
||||||
'INVENTORY_RECEIVED',
|
'INVENTORY_RECEIVED',
|
||||||
|
'INVENTORY_UNLOADED',
|
||||||
'INVENTORY_STORED',
|
'INVENTORY_STORED',
|
||||||
'INVENTORY_MOVED',
|
'INVENTORY_MOVED',
|
||||||
'INVENTORY_RESERVED',
|
'INVENTORY_RESERVED',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { WarehouseZone } from './warehouse-zone.entity';
|
|||||||
// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED
|
// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED
|
||||||
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
||||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||||
|
'UNLOADED',
|
||||||
'RECEIVED',
|
'RECEIVED',
|
||||||
'STORED',
|
'STORED',
|
||||||
'RESERVED',
|
'RESERVED',
|
||||||
@@ -27,6 +28,9 @@ export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[num
|
|||||||
|
|
||||||
/** Allowed forward transitions for the inventory lifecycle. */
|
/** Allowed forward transitions for the inventory lifecycle. */
|
||||||
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
|
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'],
|
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
STORED: ['RESERVED'],
|
STORED: ['RESERVED'],
|
||||||
RESERVED: ['READY_FOR_LOADING'],
|
RESERVED: ['READY_FOR_LOADING'],
|
||||||
@@ -111,6 +115,10 @@ export class WarehouseInventory extends BaseEntity {
|
|||||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||||
arrivedAt?: Date | null;
|
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 })
|
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
|
||||||
storedAt?: Date | null;
|
storedAt?: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ export class WarehouseInventoryController {
|
|||||||
return this.scheduling.importTrainDetail(scheduleId);
|
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')
|
@Get('loadable-wagons')
|
||||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||||
loadableWagons() {
|
loadableWagons() {
|
||||||
|
|||||||
@@ -175,6 +175,13 @@ export interface BulkDispatchResult {
|
|||||||
results: { inventoryId: string; status: string; reason?: string }[];
|
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()
|
@Injectable()
|
||||||
export class WarehouseInventoryService {
|
export class WarehouseInventoryService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -714,6 +721,154 @@ export class WarehouseInventoryService {
|
|||||||
return result;
|
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
|
* 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.
|
* report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.
|
||||||
|
|||||||
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal file
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal 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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,12 @@ import { extractErrorMessage } from './options';
|
|||||||
interface InventoryWorkbenchProps {
|
interface InventoryWorkbenchProps {
|
||||||
items: WarehouseInventoryItem[];
|
items: WarehouseInventoryItem[];
|
||||||
isLoading?: boolean;
|
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). */
|
/** 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 { toast } = useToast();
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
@@ -152,6 +154,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
|||||||
onHistory={setHistoryItem}
|
onHistory={setHistoryItem}
|
||||||
onInspect={setInspectItem}
|
onInspect={setInspectItem}
|
||||||
onFeePreview={setFeeItem}
|
onFeePreview={setFeeItem}
|
||||||
|
onLastMile={onLastMile}
|
||||||
selectedIds={selected}
|
selectedIds={selected}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
onToggleSelectAll={toggleSelectAll}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lu
|
|||||||
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import {
|
import {
|
||||||
|
useAutoUnloadArrivedBookings,
|
||||||
useBulkDispatchExport,
|
useBulkDispatchExport,
|
||||||
useBulkReceive,
|
useBulkReceive,
|
||||||
useEligibleBookings,
|
useEligibleBookings,
|
||||||
@@ -29,12 +30,13 @@ import {
|
|||||||
useLoadedExport,
|
useLoadedExport,
|
||||||
useReadyToLoadExport,
|
useReadyToLoadExport,
|
||||||
useReceiveInventory,
|
useReceiveInventory,
|
||||||
useUnloadBooking,
|
useWarehouseInventory,
|
||||||
useWarehouseYards,
|
useWarehouseYards,
|
||||||
useWarehouseZones,
|
useWarehouseZones,
|
||||||
useWarehouses,
|
useWarehouses,
|
||||||
} from '@/hooks/useWarehouses';
|
} from '@/hooks/useWarehouses';
|
||||||
import type {
|
import type {
|
||||||
|
AutoUnloadArrivedResult,
|
||||||
BulkDispatchResult,
|
BulkDispatchResult,
|
||||||
BulkReceiveResult,
|
BulkReceiveResult,
|
||||||
ImportTrain,
|
ImportTrain,
|
||||||
@@ -43,8 +45,8 @@ import type {
|
|||||||
ReadyToLoadRow,
|
ReadyToLoadRow,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { warehouseService } from '@/services/warehouse.service';
|
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
|
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||||
import { extractErrorMessage, formatDate, formatNumber } from './options';
|
import { extractErrorMessage, formatDate, formatNumber } from './options';
|
||||||
|
|
||||||
interface ReceiveInventoryModalProps {
|
interface ReceiveInventoryModalProps {
|
||||||
@@ -721,44 +723,34 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
|||||||
|
|
||||||
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
|
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
|
||||||
function ImportArriveQueueTab({
|
function ImportArriveQueueTab({
|
||||||
location,
|
|
||||||
enabled,
|
enabled,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
location: Location;
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: trains = [], isLoading } = useImportArriveQueue(enabled);
|
const { data: trains = [], isLoading } = useImportArriveQueue(enabled);
|
||||||
const unloadBooking = useUnloadBooking();
|
const autoUnloadMutation = useAutoUnloadArrivedBookings();
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [busyId, setBusyId] = 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) => {
|
const autoUnload = async (train: ImportTrain) => {
|
||||||
setBusyId(train.scheduleId);
|
setBusyId(train.scheduleId);
|
||||||
try {
|
try {
|
||||||
const items = (await warehouseService.importTrainItems(train.scheduleId)).data;
|
const res = (await autoUnloadMutation.mutateAsync(train.scheduleId)) as {
|
||||||
if (items.length === 0) {
|
data: AutoUnloadArrivedResult;
|
||||||
toast({ title: 'No bookings to unload on this train' });
|
};
|
||||||
return;
|
const r = res.data;
|
||||||
}
|
const extra = [
|
||||||
let ok = 0;
|
r.skippedCount ? `${r.skippedCount} skipped` : '',
|
||||||
for (const it of items) {
|
r.failedCount ? `${r.failedCount} failed` : '',
|
||||||
try {
|
]
|
||||||
await unloadBooking.mutateAsync({ bookingId: it.bookingId, payload: locationPayload });
|
.filter(Boolean)
|
||||||
ok += 1;
|
.join(', ');
|
||||||
} catch {
|
|
||||||
/* already unloaded / not eligible — skip */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast({
|
toast({
|
||||||
title: `Auto-unloaded ${ok}/${items.length} booking(s)`,
|
title: `${r.unloadedCount} unloaded`,
|
||||||
description: ok < items.length ? `${items.length - ok} skipped (already unloaded or not eligible)` : undefined,
|
description: extra || undefined,
|
||||||
});
|
});
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -839,7 +831,7 @@ function ImportArriveQueueTab({
|
|||||||
loading={busyId === t.scheduleId}
|
loading={busyId === t.scheduleId}
|
||||||
onClick={() => autoUnload(t)}
|
onClick={() => autoUnload(t)}
|
||||||
>
|
>
|
||||||
Auto Unload
|
Auto Unload Arrived Bookings
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</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. */
|
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||||
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
||||||
@@ -897,12 +917,10 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="arrive-queue">
|
<Tabs.Panel value="arrive-queue">
|
||||||
<ImportArriveQueueTab location={location} enabled={opened} onChanged={onReceived} />
|
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="unloaded-queue">
|
<Tabs.Panel value="unloaded-queue">
|
||||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
<ImportUnloadedQueueTab enabled={opened} />
|
||||||
Unloaded Queue — coming in the next batch.
|
|
||||||
</Text>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="dispatch-queue">
|
<Tabs.Panel value="dispatch-queue">
|
||||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
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 type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { getNextInventoryAction } from '@/types/warehouse';
|
import { getNextInventoryAction } from '@/types/warehouse';
|
||||||
@@ -14,6 +14,8 @@ interface WarehouseInventoryTableProps {
|
|||||||
onHistory: (item: WarehouseInventoryItem) => void;
|
onHistory: (item: WarehouseInventoryItem) => void;
|
||||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||||
onFeePreview?: (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).
|
// Optional row selection (used for bulk Mark-as-Inspected).
|
||||||
selectedIds?: Set<string>;
|
selectedIds?: Set<string>;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
@@ -48,6 +50,7 @@ export function WarehouseInventoryTable({
|
|||||||
onHistory,
|
onHistory,
|
||||||
onInspect,
|
onInspect,
|
||||||
onFeePreview,
|
onFeePreview,
|
||||||
|
onLastMile,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
@@ -171,6 +174,13 @@ export function WarehouseInventoryTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip label="History" withArrow>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||||
<History size={16} />
|
<History size={16} />
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||||
|
UNLOADED: 'indigo',
|
||||||
RECEIVED: 'yellow',
|
RECEIVED: 'yellow',
|
||||||
STORED: 'blue',
|
STORED: 'blue',
|
||||||
RESERVED: 'grape',
|
RESERVED: 'grape',
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ export const URL_CONSTANTS = {
|
|||||||
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
||||||
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
||||||
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
||||||
|
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
|
||||||
},
|
},
|
||||||
|
|
||||||
WAREHOUSE_LOADINGS: {
|
WAREHOUSE_LOADINGS: {
|
||||||
|
|||||||
@@ -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) ────────────────────────────────────────────────────────
|
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useLoadableWagons(enabled = true) {
|
export function useLoadableWagons(enabled = true) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
BulkDispatchResult,
|
BulkDispatchResult,
|
||||||
ImportTrain,
|
ImportTrain,
|
||||||
ImportTrainItem,
|
ImportTrainItem,
|
||||||
|
AutoUnloadArrivedResult,
|
||||||
ReserveInventoryPayload,
|
ReserveInventoryPayload,
|
||||||
SaveWarehousePayload,
|
SaveWarehousePayload,
|
||||||
SaveYardPayload,
|
SaveYardPayload,
|
||||||
@@ -146,6 +147,11 @@ export const warehouseService = {
|
|||||||
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
|
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
|
||||||
importTrainItems: (scheduleId: string) =>
|
importTrainItems: (scheduleId: string) =>
|
||||||
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
|
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) =>
|
move: (id: string, payload: MoveInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
|
||||||
movements: (id: string) =>
|
movements: (id: string) =>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const WAREHOUSE_ZONE_TYPES = [
|
|||||||
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
||||||
|
|
||||||
export const INVENTORY_STATUSES = [
|
export const INVENTORY_STATUSES = [
|
||||||
|
'UNLOADED',
|
||||||
'RECEIVED',
|
'RECEIVED',
|
||||||
'STORED',
|
'STORED',
|
||||||
'RESERVED',
|
'RESERVED',
|
||||||
@@ -52,6 +53,7 @@ export type InventoryAction =
|
|||||||
* {@link getNextInventoryAction} which resolves those at runtime.
|
* {@link getNextInventoryAction} which resolves those at runtime.
|
||||||
*/
|
*/
|
||||||
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
|
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
|
||||||
|
UNLOADED: 'store',
|
||||||
RECEIVED: 'store',
|
RECEIVED: 'store',
|
||||||
STORED: 'reserve',
|
STORED: 'reserve',
|
||||||
RESERVED: 'ready-for-loading',
|
RESERVED: 'ready-for-loading',
|
||||||
@@ -72,6 +74,7 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
|||||||
const isImport = item.booking?.tradeDirection === 'IMPORT';
|
const isImport = item.booking?.tradeDirection === 'IMPORT';
|
||||||
|
|
||||||
switch (item.status) {
|
switch (item.status) {
|
||||||
|
case 'UNLOADED':
|
||||||
case 'RECEIVED':
|
case 'RECEIVED':
|
||||||
// Import goods skip storage; they need inspection before pickup.
|
// Import goods skip storage; they need inspection before pickup.
|
||||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||||
@@ -206,6 +209,8 @@ export interface InventoryBookingRef {
|
|||||||
status?: string | null;
|
status?: string | null;
|
||||||
paymentStatus?: string | null;
|
paymentStatus?: string | null;
|
||||||
tradeDirection?: 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 {
|
export interface InventoryMovement {
|
||||||
@@ -414,6 +419,13 @@ export interface ImportTrain {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoUnloadArrivedResult {
|
||||||
|
unloadedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportTrainItem {
|
export interface ImportTrainItem {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user