Merge pull request #754 from Tria-plc/export-train-loading

Export train loading
This commit is contained in:
Hagernesh Tadesse
2026-07-17 11:03:35 +03:00
committed by GitHub
3 changed files with 145 additions and 20 deletions

View 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
)`;

View File

@@ -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,58 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
* loaded onto the wagons allocated to the booking, so anything still in the
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
* booking has warehouse inventory that never made it onto a wagon (received /
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
* or drop the booking's wagon allocation so it rides a later train.
*
* Import/domestic are untouched: their cargo isn't loaded out of an origin
* warehouse, so warehouse inventory says nothing about what's aboard.
*
* 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 [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
await this.dataSource.query(
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
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],
);
if (!route) return;
const direction = deriveTradeDirection(
{ country: route.originCountry },
{ country: route.destinationCountry },
);
if (direction !== 'EXPORT') return;
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 +2091,8 @@ export class TrainSchedulingService {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
// Export only: don't leave received cargo behind in the warehouse.
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);

View File

@@ -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';
@@ -1031,6 +1032,8 @@ export class WarehouseInventoryService {
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
continue;
}
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
// onto a train without one. Import GRN handling is left untouched.
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
@@ -1040,6 +1043,9 @@ export class WarehouseInventoryService {
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
...(booking.tradeDirection === 'EXPORT'
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
: {}),
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
});
result.processedCount += 1;
@@ -1060,6 +1066,14 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
// a train without one. Import GRN handling is left untouched.
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const isExport = bookingRow?.tradeDirection === 'EXPORT';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1080,6 +1094,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
// Export only, and keep an already-issued GRN rather than reissuing.
...(isExport && !existing[0].grnNumber
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1094,6 +1112,9 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
...(isExport
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
: {}),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1542,11 +1563,19 @@ export class WarehouseInventoryService {
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
/**
* 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 = SCHEDULE_BOOKINGS_CTE;
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
@@ -1554,15 +1583,15 @@ export class WarehouseInventoryService {
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
(SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
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','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = ts.id
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
@@ -1570,11 +1599,11 @@ export class WarehouseInventoryService {
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
SELECT 1 FROM sched_bookings sb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
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','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
@@ -1599,22 +1628,28 @@ export class WarehouseInventoryService {
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
-- receive() stamps the GRN onto the row and mirrors it into the
-- note; prefer the column and fall back for legacy/seeded rows.
COALESCE(
inv.grn_number,
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
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
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
@@ -1631,15 +1666,19 @@ export class WarehouseInventoryService {
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
WHERE sb.schedule_id = $1
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],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
// 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.
loadable:
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
}));
}
@@ -1692,6 +1731,9 @@ export class WarehouseInventoryService {
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
// Export: the GRN is raised when the goods arrive at the warehouse, and
// 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; }
try {