From 9eb10bb4ce9bdf917e1e317c6d2d15c1152b2697 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 07:53:13 +0000 Subject: [PATCH 01/16] feat(warehouses): auto-load targets a selected train, never loads trainless "Auto Load Ready Items" now opens a train picker (pre-dispatch trains with these bookings assigned, via the existing loadable-trains flow). No train available -> no auto-loading, with a clear notice. Loading goes through the existing per-wagon load path, so items without an allocated wagon are skipped with a reason. The train association is stored on the existing warehouse_loadings table (no new table needed): new train_schedule_id column + a note recording train number, origin -> destination, and departure time; wagon_id becomes nullable. The trainless load-passed-export endpoint, its frontend wiring, and the unused useLoadPassedExport hook are removed. Migration 2100000000000 (idempotent) also applied to the dev database. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...000000-WarehouseLoadingTrainAssociation.ts | 35 ++++++++ .../warehouses/dto/load-inventory.dto.ts | 5 ++ .../entities/warehouse-loading.entity.ts | 14 ++- .../warehouse-inventory.controller.ts | 5 -- .../warehouses/warehouse-inventory.service.ts | 81 +++++++---------- .../warehouses/ReceiveInventoryModal.tsx | 89 +++++++++++++++++-- .../backoffice/src/hooks/useWarehouses.ts | 2 - .../backoffice/src/services/api.ts | 9 -- .../src/services/warehouse.service.ts | 3 - 9 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts diff --git a/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts new file mode 100644 index 000000000..26afdf82a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Auto-load onto a selected train: a warehouse_loadings row now records WHICH + * train the item was loaded onto (train_schedule_id), and wagon_id becomes + * nullable because a schedule-level load may not resolve to a single wagon. + */ +export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface { + name = 'WarehouseLoadingTrainAssociation2100000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ALTER COLUMN wagon_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule + ON freight.warehouse_loadings(train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id + `); + // wagon_id stays nullable on revert: restoring NOT NULL would fail on rows + // recorded without a wagon and re-introduce the outage this fixes. + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index c81550cd0..3c9c6c85f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,6 +6,11 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Train schedule this load belongs to (recorded on the loading).' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts index 5f6952aec..1698b8a5e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -28,9 +28,17 @@ export class WarehouseLoading extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking | null; - /** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */ - @Column({ name: 'wagon_id', type: 'uuid' }) - wagonId!: string; + /** + * Physical wagon the item was loaded onto. References freight.wagons + * (read-only link). Nullable: a schedule-level auto-load may not resolve to + * one wagon — the train association then lives in trainScheduleId. + */ + @Column({ name: 'wagon_id', type: 'uuid', nullable: true }) + wagonId?: string | null; + + /** Train schedule the item was loaded onto (read-only link to scheduling). */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; @Column({ name: 'loaded_at', type: 'timestamptz' }) loadedAt!: Date; 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 242cecc76..070a267a4 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 @@ -77,11 +77,6 @@ export class WarehouseInventoryController { return this.inventoryService.bulkReceive(dto); } - @Post('load-passed-export') - @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) - loadPassedExport(@Body('performedBy') performedBy?: string) { - return this.inventoryService.loadPassedExport(performedBy); - } @Get('ready-to-load-export') @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) 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 92617db6d..8f7a71fc1 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 @@ -243,11 +243,6 @@ export interface BulkReceiveResult { results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } -export interface LoadPassedExportResult { - loadedCount: number; - skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; -} export interface BulkInspectResult { inspectedCount: number; @@ -1098,46 +1093,6 @@ export class WarehouseInventoryService { return result; } - /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ - async loadPassedExport(performedBy?: string): Promise { - const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); - const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; - - for (const item of ready) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); - }; - - if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } - const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; - if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(item.id, { - status: 'LOADED', - loadedAt: new Date(), - }); - await this.activityLog.record( - { - activityType: 'INVENTORY_LOADED', - inventoryId: item.id, - warehouseId: item.warehouseId, - description: 'Bulk loaded (passed export)', - performedBy, - }, - manager, - ); - }); - - result.loadedCount += 1; - result.results.push({ inventoryId: item.id, status: 'LOADED' }); - } - - return result; - } /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ private async exportInventoryByStatus( @@ -1319,6 +1274,29 @@ export class WarehouseInventoryService { performedBy?: string, ): Promise { const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + const [schedule]: Array<{ + trainNumber: string | null; + origin: string | null; + destination: string | null; + departure: string | null; + }> = await this.dataSource.query( + `SELECT ts.train_number AS "trainNumber", + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", + ts.scheduled_departure_date AS "departure" + 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`, + [scheduleId], + ); + const trainNote = schedule + ? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` + + (schedule.origin || schedule.destination + ? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})` + : '') + + (schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '') + : undefined; const items = await this.trainLoadableItems(scheduleId); const byId = new Map(items.map((i) => [i.id, i])); const affectedBookingIds = new Set(); @@ -1335,7 +1313,12 @@ export class WarehouseInventoryService { if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { - await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy }); + await this.load(inventoryId, { + wagonId: item.wagonId, + loadedBy: performedBy, + trainScheduleId: scheduleId, + notes: trainNote, + }); result.loadedCount += 1; result.results.push({ inventoryId, status: 'LOADED' }); if (item.bookingId) affectedBookingIds.add(item.bookingId); @@ -3403,6 +3386,8 @@ export class WarehouseInventoryService { warehouseInventoryId: id, bookingId: item.bookingId ?? null, wagonId: dto.wagonId, + // Which train this load belongs to — durable even if wagons reshuffle. + trainScheduleId: dto.trainScheduleId ?? null, loadedAt: now, loadedBy: dto.loadedBy ?? null, loadedWeight, @@ -3441,7 +3426,7 @@ export class WarehouseInventoryService { }); // Enrich with wagon numbers (read-only lookup into the scheduling domain). - const wagonIds = [...new Set(loadings.map((l) => l.wagonId))]; + const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))]; const wagonNumbers = new Map(); if (wagonIds.length > 0) { const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( @@ -3452,7 +3437,7 @@ export class WarehouseInventoryService { } return loadings.map((loading) => - Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }), ); } 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 de41c1a82..ff8dc0abd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1414,8 +1414,30 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: const { data: rows = [], isLoading } = useQuery( api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); - const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); + const qc = useQueryClient(); const [selected, setSelected] = useState>(new Set()); + const [trainPickerOpen, setTrainPickerOpen] = useState(false); + const [targetScheduleId, setTargetScheduleId] = useState(null); + // Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + queryKey: ['warehouse-inventory', 'loadable-trains'], + queryFn: () => warehouseService.getLoadableTrains(), + enabled: enabled && trainPickerOpen, + }); + const loadOntoTrain = useMutation({ + mutationFn: async (scheduleId: string) => { + const items = await warehouseService.getTrainLoadableItems(scheduleId); + const loadableIds = items.filter((i) => i.loadable).map((i) => i.id); + if (!loadableIds.length) { + throw new Error('No ready items with an allocated wagon on this train'); + } + return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); + }, + }); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -1427,13 +1449,22 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: return next; }); - const autoLoad = async () => { + const confirmLoad = async () => { + if (!targetScheduleId) { + toast({ variant: 'destructive', title: 'Select a train to load onto' }); + return; + } try { - const r = await loadPassed.mutateAsync(undefined); + const r = await loadOntoTrain.mutateAsync(targetScheduleId); + const train = trains.find((t) => t.scheduleId === targetScheduleId); toast({ - title: `${r.loadedCount} items loaded`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(), + description: r.skippedCount + ? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}` + : undefined, }); + setTrainPickerOpen(false); + setTargetScheduleId(null); setSelected(new Set()); onChanged?.(); } catch (error) { @@ -1452,14 +1483,58 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: variant="filled" color="teal" leftSection={} - loading={loadPassed.isPending} disabled={rows.length === 0} - onClick={autoLoad} + onClick={() => setTrainPickerOpen(true)} > Auto Load Ready Items + setTrainPickerOpen(false)} + title="Load ready items onto a train" + centered + size="lg" + > + + {trainsLoading ? ( + + ) : trains.length === 0 ? ( + }> + No train available. Auto-loading needs a scheduled (not yet dispatched) train with + these bookings assigned — schedule the train and allocate wagons first. + + ) : ( +