mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user