From 3721ac1ef0b0bbae0c77f7a9ef409dacb04523de Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 1 Sep 2026 03:01:48 +0000 Subject: [PATCH 1/3] feat(warehouses): gate loading queues on the train's station loading window Warehouse loading queues and the train schedule page now share one truth: the schedule's per-yard stationWorkLogs. A booking's items aren't loadable until "Start loading" has been clicked for their boarding yard, mirroring the same assertStationWorkStarted check the train schedule page's own Load button already enforces. - LoadableTrainRow/LoadableTrain carry originStationId + stationWorkLogs. - TrainLoadableItem carries originYardId/originYardLabel/loadingWindowStarted, computed from station_work_logs in the same query. - New YardLoadingWindows component surfaces the per-yard windows on the loading panel; ReceiveInventoryModal and LoadToTrainPanel wire it in. - Invalidate loadable-trains/train-loadable-items/warehouse-inventory queries alongside train-scheduling ones, since they render off the same data. Fixes the StationWorkLogJson typo that broke the freight-api build. --- .../train-loading-window-gate.spec.ts | 77 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 30 +++++++- .../warehouses/LoadToTrainPanel.tsx | 6 ++ .../warehouses/ReceiveInventoryModal.tsx | 24 ++++++ .../warehouses/YardLoadingWindows.tsx | 77 +++++++++++++++++++ .../backoffice/src/services/api.ts | 5 ++ .../src/services/warehouse.service.ts | 10 +++ 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/YardLoadingWindows.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts new file mode 100644 index 000000000..fe9adb389 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts @@ -0,0 +1,77 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import type { TrainLoadableItemRow } from './warehouse-inventory.service'; + +/** + * Cargo may only go onto a wagon inside a STARTED loading window at its + * boarding yard — the same rule the train schedule's own Load button enforces + * (assertStationWorkStarted). The warehouse loading queues load through a + * different service, so the rule is mirrored here; without it the two surfaces + * disagree and the queue offers a Load the schedule would refuse. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing every collaborator. + */ +const row = (over: Partial = {}): TrainLoadableItemRow => + ({ + id: 'inv-1', + bookingId: 'b-1', + bookingReference: 'BK-1', + customerName: 'Acme', + containerNumber: 'CN-1', + cargoType: 'General', + weight: 20, + grnNumber: 'GRN-1', + inspectionStatus: 'PASSED', + status: 'READY_FOR_LOADING', + wagonId: 'w-1', + wagonNumber: 'W-001', + sequenceNo: 1, + originYardId: 'yard-1', + originYardLabel: 'Modjo', + loadingWindowStarted: true, + loadable: true, + ...over, + }) as TrainLoadableItemRow; + +function makeService(items: TrainLoadableItemRow[]) { + const query = jest.fn().mockResolvedValue([ + { trainNumber: 'T-100', origin: 'Modjo', destination: 'Djibouti', departure: null }, + ]); + const load = jest.fn().mockResolvedValue(undefined); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + service.load = load; + service.trainLoadableItems = jest.fn().mockResolvedValue(items); + return { service: service as unknown as WarehouseInventoryService, load }; +} + +describe('loadItemsOntoTrain() — station loading window gate', () => { + it('skips an item whose boarding yard has no started loading window', async () => { + const { service, load } = makeService([row({ loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).not.toHaveBeenCalled(); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.results[0].reason).toContain('Start loading at Modjo first'); + }); + + it('loads once the window is started', async () => { + const { service, load } = makeService([row()]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).toHaveBeenCalledTimes(1); + expect(result.loadedCount).toBe(1); + expect(result.skippedCount).toBe(0); + }); + + it('still reports the wagon blocker first — the window is not the only gate', async () => { + const { service } = makeService([row({ wagonId: null, loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(result.results[0].reason).toContain('No wagon allocated'); + }); +}); 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 653c2ea5a..5956f0bcf 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 @@ -19,6 +19,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; +import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; import { Container } from '../container-management/entities/container.entity'; @@ -326,7 +327,7 @@ export interface LoadableTrainRow { * schedule page stores them. The warehouse loading queues render the same * Start/End controls off this, so both surfaces show one truth. */ - stationWorkLogs: Record | null; + stationWorkLogs: Record | null; /** Received/ready inventory not yet loaded onto this train. */ readyCount: number; /** Inventory already loaded onto this train. */ @@ -1855,6 +1856,8 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", + ts.origin_station_id AS "originStationId", + ts.station_work_logs AS "stationWorkLogs", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1918,7 +1921,15 @@ export class WarehouseInventoryService { inv.status AS "status", wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", - wl.sequence_no AS "sequenceNo" + wl.sequence_no AS "sequenceNo", + COALESCE(b.origin_yard_id, ts.origin_station_id) AS "originYardId", + COALESCE(oy.label, oy.code) AS "originYardLabel", + -- Same rule the train schedule's own Load button obeys + -- (assertStationWorkStarted): the yard's loading window must have + -- been started before its cargo may go on a wagon. + (ts.station_work_logs #>> ARRAY[ + COALESCE(b.origin_yard_id, ts.origin_station_id)::text, 'loading', 'startedAt' + ]) IS NOT NULL AS "loadingWindowStarted" FROM sched_bookings sb JOIN freight.train_schedules ts ON ts.id = sb.schedule_id JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL @@ -1926,6 +1937,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.yards oy ON oy.id = COALESCE(b.origin_yard_id, ts.origin_station_id) LEFT JOIN LATERAL ( SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no FROM freight.wagon_booking_allocations wba @@ -1948,9 +1960,14 @@ export class WarehouseInventoryService { ...r, // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. // The row only exists once the goods were received, so requiring a GRN and - // an allocated wagon completes the chain. + // an allocated wagon completes the chain. The yard's loading window is the + // fourth link — the warehouse queue must not offer what the train + // schedule's own Load button would refuse. loadable: - r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), + r.status === 'READY_FOR_LOADING' && + Boolean(r.wagonId) && + Boolean(r.grnNumber) && + r.loadingWindowStarted, })); } @@ -2007,6 +2024,11 @@ export class WarehouseInventoryService { // nothing rides a train without one. if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } + // Mirrors assertStationWorkStarted on the train-schedule load path. + if (!item.loadingWindowStarted) { + skip(`Start loading at ${item.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`); + continue; + } try { await this.load(inventoryId, { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx index 828f4ab44..6b0e0f1b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx @@ -22,6 +22,7 @@ import { type TrainLoadableItem, } from '@/services/warehouse.service'; import { extractErrorMessage } from './options'; +import { YardLoadingWindows } from './YardLoadingWindows'; const STAGE_COLOR: Record = { RECEIVED: 'blue', @@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande ) : ( + {bookings.map((b) => ( ))} 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 f87d6d24d..ba75f0c35 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -90,6 +90,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { YardLoadingWindows } from './YardLoadingWindows'; import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; import ListControls from '@/components/common/ListControls'; @@ -1598,6 +1599,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: queryFn: () => warehouseService.getLoadableTrains(), enabled: enabled && trainPickerOpen, }); + const { data: pickerItems = [] } = useQuery({ + queryKey: ['train-loadable-items', targetScheduleId], + queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!), + enabled: Boolean(targetScheduleId) && trainPickerOpen, + }); const loadOntoTrain = useMutation({ mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => { const items = await warehouseService.getTrainLoadableItems(scheduleId); @@ -1608,6 +1614,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: loadableIds = loadableIds.filter((id) => picked.has(id)); } if (!loadableIds.length) { + const scope = onlyIds.length + ? items.filter((i) => onlyIds.includes(i.id)) + : items.filter((i) => i.status === 'READY_FOR_LOADING'); + // A closed loading window is the blocker staff hit most, and the old + // wagon-only message sent them to fix the wrong thing. + const shut = scope.find((i) => !i.loadingWindowStarted); + if (shut) { + throw new Error( + `Start loading at ${shut.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`, + ); + } throw new Error( onlyIds.length ? 'None of the selected items have an allocated wagon on this train' @@ -1701,6 +1718,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: searchable /> )} + {targetScheduleId ? ( + t.scheduleId === targetScheduleId)?.stationWorkLogs} + /> + ) : null}