feat(train): enhance train history and scheduling features

- Added a reason field to train history entries for detach/maintenance actions.
- Updated TrainHistoryPanel to display the reason for wagon detachments.
- Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons.
- Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason.
- Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments.
- Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
This commit is contained in:
Marshal
2026-08-28 07:33:28 +00:00
parent 8b8870e85e
commit ba56974e32
26 changed files with 1435 additions and 583 deletions

View File

@@ -2946,6 +2946,10 @@ export class TrainSchedulingService {
// The yard plan this departure was SOLD against must match where the steel
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
await this.assertPlannedYardsAligned(schedule);
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
// every wagon must be LOADED, or the never-loaded remainder cancelled
// (at-loading cancellation), before the train departs.
await this.assertNoPartiallyLoadedBookings(schedule);
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -3144,6 +3148,42 @@ export class TrainSchedulingService {
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
* (their charge sits on the credit ledger) yet ride from accept.
*/
/**
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
* here beats silently unassigning: unassign would delete LOADED allocations
* and strand cargo that is physically on the train.
*/
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
if (!schedule.trainSetId) return;
const rows: Array<{ reference: string; loaded: string; total: string }> =
await this.dataSource.query(
`SELECT b.reference,
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
COUNT(*) AS total
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.bookings b ON b.id = a.booking_id
WHERE tsw.train_set_id = $1
AND a.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND b.deleted_at IS NULL
GROUP BY b.id, b.reference
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
[schedule.trainSetId],
);
if (rows.length) {
const detail = rows
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
.join(', ');
throw new BadRequestException(
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
);
}
}
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
@@ -3157,6 +3197,7 @@ export class TrainSchedulingService {
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND b.loading_started_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false
AND (b.status = 'PAID'