mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(train-scheduling): block dispatch when allocated cargo is not loaded
dispatchSchedule guarded status, Djibouti departure rules, locomotives and wagons — but never checked the cargo. A train could be dispatched while the bookings allocated to it sat received in the warehouse, silently leaving them behind. Dispatch now refuses when an allocated booking has warehouse inventory in RECEIVED/STORED/READY_FOR_LOADING, naming the bookings and pointing at the two ways out: load them, or drop the wagon allocation so they ride a later train. Bookings with no inventory at all are not blocked — allocating a wagon before the goods arrive is normal planning. Also drops RESERVED from the Load-to-Train filters: reserved stock is not awaiting loading. The sched_bookings CTE moves to common/schedule-bookings.sql so the warehouse loading queue and this dispatch guard resolve a train's bookings identically — if they drift, a train departs leaving cargo the warehouse still expects to load. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
|
||||
* (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
|
||||
*
|
||||
* A booking reaches a train through WAGON ALLOCATION
|
||||
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
|
||||
* which is what the allocation UI writes. `train_schedule_bookings` is only ever
|
||||
* written by the demo seeders, so both sources are unioned: real allocations work
|
||||
* and the seeded scenarios keep working.
|
||||
*
|
||||
* Shared so the warehouse loading queue and the train dispatch guard agree on
|
||||
* exactly which bookings are on a train — if they drift, a train can be
|
||||
* dispatched leaving cargo the warehouse still thinks it should load.
|
||||
*/
|
||||
export const SCHEDULE_BOOKINGS_CTE = `
|
||||
sched_bookings AS (
|
||||
SELECT ts.id AS schedule_id, wba.booking_id
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||
WHERE ts.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT tsb.train_schedule_id, tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.deleted_at IS NULL
|
||||
)`;
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
@@ -2029,6 +2030,37 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A train must not leave carrying nothing while its cargo sits in the shed.
|
||||
* Blocks dispatch when a booking allocated to this train has warehouse
|
||||
* inventory that never made it onto a wagon (received / stored / ready but not
|
||||
* LOADED). Either load it from the warehouse Load-to-Train queue, or drop the
|
||||
* booking's wagon allocation so it travels on a later train.
|
||||
*
|
||||
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
|
||||
* wagon before the goods arrive is normal planning; they simply aren't aboard.
|
||||
*/
|
||||
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
|
||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = $1
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
|
||||
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -2038,6 +2070,8 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// Don't leave received cargo behind on the platform.
|
||||
await this.assertAllocatedCargoLoaded(scheduleId);
|
||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Company } from '../companies/entities/company.entity';
|
||||
@@ -1543,30 +1544,11 @@ export class WarehouseInventoryService {
|
||||
|
||||
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
||||
/**
|
||||
* The bookings riding a train schedule.
|
||||
*
|
||||
* Export flow: booked -> paid -> received at the warehouse (first-mile or
|
||||
* self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking
|
||||
* actually reaches a train through WAGON ALLOCATION
|
||||
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
|
||||
* which is what the allocation UI writes. `train_schedule_bookings` is only
|
||||
* ever written by the demo seeders — keying off it alone left this queue
|
||||
* permanently empty for real traffic — so both sources are unioned.
|
||||
* Export flow this queue serves: booked -> paid -> received at the warehouse
|
||||
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
|
||||
* booking. Which bookings ride a train comes from the shared CTE.
|
||||
*/
|
||||
private readonly SCHEDULE_BOOKINGS_CTE = `
|
||||
sched_bookings AS (
|
||||
SELECT ts.id AS schedule_id, wba.booking_id
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||
WHERE ts.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT tsb.train_schedule_id, tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.deleted_at IS NULL
|
||||
)`;
|
||||
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
|
||||
|
||||
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
||||
const rows: Array<
|
||||
@@ -1585,7 +1567,7 @@ export class WarehouseInventoryService {
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = ts.id
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
|
||||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
|
||||
(SELECT count(*) FROM sched_bookings sb
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||
@@ -1601,7 +1583,7 @@ export class WarehouseInventoryService {
|
||||
JOIN freight.warehouse_inventory inv2
|
||||
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
|
||||
WHERE sb2.schedule_id = ts.id
|
||||
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||
)
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[['DRAFT', 'SCHEDULED']],
|
||||
@@ -1660,7 +1642,7 @@ export class WarehouseInventoryService {
|
||||
LIMIT 1
|
||||
) wl ON true
|
||||
WHERE sb.schedule_id = $1
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user