diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -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 + )`; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b92a7ea90..97984be5f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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); 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 70abf635b..3ad71b6fc 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 @@ -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 { 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], );