mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 13:35:03 +00:00
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.
This commit is contained in:
@@ -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> = {}): 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<string, unknown>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, StationWorkLogJson> | null;
|
||||
stationWorkLogs: Record<string, StationWorkLog> | 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, {
|
||||
|
||||
Reference in New Issue
Block a user