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) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-10 07:53:13 +00:00
parent 8dd3f32a33
commit 9eb10bb4ce
9 changed files with 166 additions and 77 deletions

View File

@@ -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<void> {
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<void> {
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.
}
}

View File

@@ -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()

View File

@@ -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;

View File

@@ -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' })

View File

@@ -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<LoadPassedExportResult> {
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<TrainLoadResult> {
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<string>();
@@ -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<string, string>();
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 }),
);
}

View File

@@ -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<Set<string>>(new Set());
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(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={<Truck size={14} />}
loading={loadPassed.isPending}
disabled={rows.length === 0}
onClick={autoLoad}
onClick={() => setTrainPickerOpen(true)}
>
Auto Load Ready Items
</Button>
</Group>
<Modal
opened={trainPickerOpen}
onClose={() => setTrainPickerOpen(false)}
title="Load ready items onto a train"
centered
size="lg"
>
<Stack gap="md">
{trainsLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
) : trains.length === 0 ? (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
No train available. Auto-loading needs a scheduled (not yet dispatched) train with
these bookings assigned schedule the train and allocate wagons first.
</Alert>
) : (
<Select
label="Available trains"
placeholder="Select the train to load onto"
data={trains.map((t) => ({
value: t.scheduleId,
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'}${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`,
}))}
value={targetScheduleId}
onChange={setTargetScheduleId}
searchable
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
Cancel
</Button>
<Button
color="teal"
leftSection={<Truck size={14} />}
loading={loadOntoTrain.isPending}
disabled={!targetScheduleId}
onClick={confirmLoad}
>
Load onto this train
</Button>
</Group>
</Stack>
</Modal>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />

View File

@@ -236,8 +236,6 @@ export function useEligibleBookings(enabled = true) {
}
export const useBulkReceive = () =>
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
export const useLoadPassedExport = () =>
useInventoryMutation(() => warehouseService.loadPassedExport());
export const useBulkMarkInspected = () =>
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));

View File

@@ -101,7 +101,6 @@ import type {
InitiateWarehouseInvoicePaymentPayload,
LoadableWagon,
LoadInventoryPayload,
LoadPassedExportResult,
MoveInventoryPayload,
StoreInventoryPayload,
PayInvoicePayload,
@@ -1158,14 +1157,6 @@ export const api = {
() => INVENTORY_INVALIDATIONS,
),
loadPassedExport: endpoint<void, LoadPassedExportResult>(
"warehouse-inventory",
"load-passed-export",
() => warehouseService.loadPassedExport().then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
"warehouse-inventory",
"bulk-mark-inspected",

View File

@@ -37,7 +37,6 @@ import type {
EligibleBooking,
BulkReceivePayload,
BulkReceiveResult,
LoadPassedExportResult,
BulkInspectPayload,
BulkInspectResult,
ReadyToLoadRow,
@@ -300,8 +299,6 @@ export const warehouseService = {
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
receiveBulk: (payload: BulkReceivePayload) =>
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
loadPassedExport: () =>
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
bulkMarkInspected: (payload: BulkInspectPayload) =>
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
receivedExport: () =>