mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user