diff --git a/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts new file mode 100644 index 000000000..dbb68fe61 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Why a train schedule was cancelled, captured at cancel time. Staff pick a + * reason in the cancel dialog and every view of the cancelled schedule reads it + * back — a cancelled train on the board used to say nothing about why it died. + */ +export class ScheduleCancellationReason3780000000000 implements MigrationInterface { + name = 'ScheduleCancellationReason3780000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."train_schedules" + ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500), + ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz, + ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."train_schedules" + DROP COLUMN IF EXISTS "cancellation_reason", + DROP COLUMN IF EXISTS "cancelled_at", + DROP COLUMN IF EXISTS "cancelled_by_user_id" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index ac6fb5aa3..64ccb1929 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -836,15 +836,15 @@ export class BookingWagonCancellationService { ) .where('alloc.booking_id = :bookingId', { bookingId }) .getMany(); - const loaded = allocations.filter( - (a) => a.status === 'LOADED' || a.status === 'DEPARTED', - ); const remaining = allocations.filter( (a) => a.status !== 'LOADED' && a.status !== 'DEPARTED', ); - if (!loaded.length) { + // A booking whose cargo never showed up at all (0 loaded) is cancelled the + // same way — the gate that holds the train does not care whether loading + // started, only that nothing is left unresolved. + if (!allocations.length) { throw new BadRequestException( - 'Loading has not started for this booking — use the normal wagon cancellation flow.', + 'This booking has no wagons on this schedule — use the normal wagon cancellation flow.', ); } if (!remaining.length) { diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index a1f927a77..5f24359a2 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -273,6 +273,20 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true }) wagonAllocationSnapshot?: WagonAllocationSnapshot | null; + /** + * Why this schedule was cancelled — required at cancel time and shown on every + * view of the cancelled train. NULL on live schedules and on rows cancelled + * before the reason was captured. + */ + @Column({ name: 'cancellation_reason', type: 'varchar', length: 500, nullable: true }) + cancellationReason?: string | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'cancelled_by_user_id', type: 'uuid', nullable: true }) + cancelledByUserId?: string | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9051bb816..8599a96bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => { maxWagons?: number; routeStops?: string[]; yardCountries?: Record; + maxPullWeightTons?: number; + maxTrainLengthMeters?: number; }) => { const schedule = { id: scheduleId, @@ -1390,8 +1392,11 @@ describe('BookingBatchService — built-train wagon capacity', () => { scheduleBookings: [], trainSet: { locomotive: { - maxPullWeightTons: 1, - maxTrainLengthMeters: 1, + // Roomy on purpose: these cases exercise the SLOT axis, so the pull + // budget must not be what closes the train. Weight-bound behaviour + // has its own cases below. + maxPullWeightTons: opts.maxPullWeightTons ?? 100000, + maxTrainLengthMeters: opts.maxTrainLengthMeters ?? 100000, overageToleranceTons: 0, overageToleranceMeters: 0, }, @@ -1459,15 +1464,28 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); - it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + it('is NOT full while physical wagons remain and the loco can still haul them', async () => { const { service } = buildService({ physicalWagons: 3, reserved: [reservedBooking('b1'), reservedBooking('b2')], }); - // 1T pull cap would have been exhausted long ago under the old math. await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); + it('is FULL when the locomotive cannot pull another wagon, though slots are free', async () => { + // The consist has a spare slot, but every wagon spends its TARE out of the + // same pull limit the cargo needs — so a slot-free train can still be + // weight-full. This is what let a 44-wagon booking plan 4065T gross onto a + // 3500T train while the board advertised free wagons. + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => { // Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real // capacity on the edges they don't ride: a domestic corridor with cargo diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d787f737d..bf9c0aa5f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -658,8 +658,36 @@ export class BookingBatchService implements OnModuleInit { } } - const linked = + let linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // A link row can outlive the booking's own pointer (cleared on one path + // while the row survives on another). The booking then looks "linked" here, + // so the branch below calls tryAutoWagonAllocation(null) and the paid + // booking silently never gets wagons — no error, just no allocation. + if (linked && !booking.trainScheduleId) { + // The link row still names the train it belongs to — restore the pointer + // from it rather than dropping the link, so the booking keeps the train + // it was placed on and the allocation below has a schedule to run against. + const [link] = await this.trainScheduleBookingsRepository.findByBookingIds([ + bookingId, + ]); + if (link?.trainScheduleId) { + await this.dataSource + .getRepository(Booking) + .update(bookingId, { trainScheduleId: link.trainScheduleId } as never); + booking.trainScheduleId = link.trainScheduleId; + this.logger.warn( + `[BATCH] ${booking.reference ?? bookingId} was linked to schedule ${link.trainScheduleId} ` + + `with no train_schedule_id of its own — pointer restored so it can allocate`, + ); + } else { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + link?.trainScheduleId ?? '', + bookingId, + ); + linked = false; + } + } // Intercity is allocated MANUALLY: payment secures the ride, staff then // place it on whichever same-route train suits (intercity panel). Unpin // from the train it reserved against — that train may be the wrong one by @@ -5376,10 +5404,13 @@ export class BookingBatchService implements OnModuleInit { * Dire→Djibouti leaves the Addis→Dire edges untouched. * * Two capacity regimes, decided by the schedule's train: - * - Built train (Train Builder consist with physical wagons): the consist IS - * the capacity. Wagon slots = physical wagon count; weight and length are - * NOT re-checked here — the builder and adjust-consist already enforced the - * locomotive's pull/length limits when the consist was assembled. + * - Built train (Train Builder consist with physical wagons): wagon slots = + * physical wagon count, but the locomotive's weight/length budgets STILL + * apply. The builder only proves the EMPTY consist can be pulled; every + * wagon then spends its tare out of the same pull limit the cargo needs, so + * a 54-wagon consist can be slot-free and still weight-full. Treating the + * consist as unlimited tonnage is what let a 44-wagon booking plan 4065T + * gross onto a 3500T train. * - No built train (legacy schedules): the locomotive's length-derived slot * count plus its weight/length budgets, as before — yard staff attach the * missing wagons manually before wagon assignment. @@ -5392,13 +5423,16 @@ export class BookingBatchService implements OnModuleInit { ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { + // The consist fixes the SLOT count (never the locomotive's length-derived + // estimate), but weight and length stay on the locomotive's real budget — + // including its overage tolerance, which `fits` may spend on a whole unit. limits = { base: { wagons: physicalWagons, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, + weightTons: limits.base.weightTons, + lengthMeters: limits.base.lengthMeters, }, - tolerance: { weightTons: 0, lengthMeters: 0 }, + tolerance: limits.tolerance, }; } // Built trains keep the leg-aware multi-edge corridor too: the wagon @@ -5631,19 +5665,23 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); if (physicalWagons != null) { - // The consist is the capacity; weight/length were settled at build time. - // remainingBudget swaps in the physical wagon count per edge itself. - limits = { - base: { - wagons: physicalWagons, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, - }, - tolerance: { weightTons: 0, lengthMeters: 0 }, - }; + // The consist fixes the slot count, but the locomotive's pull/length + // budget still binds: 54 empty slots are worthless once the tare of the + // wagons already loaded has spent the pull limit. Without a locomotive + // there is nothing to weigh against, so the slot axis is all that is left. + limits = locomotive + ? await this.capacityLimits(locomotive) + : { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; } else { - const locomotive = trainSetLocomotiveLimits(schedule.trainSet); // No loco, no built train: only the slot axis exists to bind against. if (!locomotive) return (await this.remainingWagons(schedule)) <= 0; limits = await this.capacityLimits(locomotive); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index fa32b601b..fbf555444 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -32,6 +32,7 @@ import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; import { StationWorkDto } from "../dto/station-work.dto"; import { AssignBookingsDto } from "../dto/assign-bookings.dto"; +import { CancelTrainScheduleDto } from "../dto/cancel-train-schedule.dto"; import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto"; @@ -1184,14 +1185,22 @@ export class TrainSchedulingController { @Post("container/schedules/:id/cancel") @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel container train schedule" }) - cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.cancelTrainSchedule(id); + cancelTrainSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelTrainScheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id); } @Post('bulk/schedules/:id/cancel') @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel bulk train schedule" }) - cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.cancelTrainSchedule(id); + cancelBulkTrainSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelTrainScheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts new file mode 100644 index 000000000..ef98e0887 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts @@ -0,0 +1,145 @@ +import { BadRequestException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard + * blocks the train; a booking that boards further down the corridor + * (A→B→C→D carrying a B→C load) never does — its wagons are not due until its + * own yard, so the SQL is scoped by `b.origin_yard_id = `. + * The scoping lives in the query, so this checks the parameters that carry it + * plus the throw/pass decision on the rows it returns. + */ +describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => { + const ORIGIN = 'yard-a'; + const SET = 'set-1'; + + const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => { + const calls: Array<{ sql: string; params: unknown[] }> = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { query: (sql: string, params: unknown[]) => Promise }; + assertNoPartiallyLoadedBookings( + schedule: unknown, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise; + assertPassedYardsFullyLoaded( + schedule: unknown, + stations: Array<{ sequenceNo: number; yardId: string; label: string }>, + sequenceNo: number, + ): Promise; + }; + svc.dataSource = { + query: async (sql: string, params: unknown[]) => { + calls.push({ sql, params }); + return rows; + }, + }; + return { svc, calls }; + }; + const schedule = { trainSetId: SET, originStationId: ORIGIN }; + + it('scopes the scan to bookings boarding at this departure yard', async () => { + const { svc, calls } = makeService([]); + await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }); + expect(calls).toHaveLength(1); + // The origin filter is what keeps a mid-corridor booking from holding the + // train — without it, one early-loaded B→C wagon blocks dispatch at A. + expect(calls[0].sql).toContain('b.origin_yard_id = $2'); + expect(calls[0].params).toEqual([SET, ORIGIN]); + }); + + it('lets the train go when nothing at this yard is half-loaded', async () => { + const { svc } = makeService([]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).resolves.toBeUndefined(); + }); + + it('blocks a booking half-loaded at this yard, naming its progress', async () => { + const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('skips the scan entirely for a schedule with no train set', async () => { + const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]); + await expect( + svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, { + action: 'dispatch', + }), + ).resolves.toBeUndefined(); + expect(calls).toHaveLength(0); + }); +}); + +/** + * Mid-corridor twin: logging a checkpoint at station N means the train left + * every earlier stop, so each of those yards is checked for its OWN + * half-loaded bookings. The origin is excluded (dispatch gated it) and the + * yard being arrived at is excluded (its loading has not happened yet). + */ +describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => { + const STATIONS = [ + { sequenceNo: 0, yardId: 'mojo', label: 'Mojo' }, + { sequenceNo: 1, yardId: 'adama', label: 'Adama' }, + { sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' }, + { sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' }, + ]; + + const makeService = (rowsByYard: Record>>) => { + const scanned: string[] = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { query: (sql: string, params: unknown[]) => Promise }; + assertPassedYardsFullyLoaded( + schedule: unknown, + stations: typeof STATIONS, + sequenceNo: number, + ): Promise; + }; + svc.dataSource = { + query: async (_sql: string, params: unknown[]) => { + const yardId = params[1] as string; + scanned.push(yardId); + return rowsByYard[yardId] ?? []; + }, + }; + return { svc, scanned }; + }; + const schedule = { trainSetId: 'set-1', originStationId: 'mojo' }; + + it('checks the stops already departed, never the origin or the yard being reached', async () => { + const { svc, scanned } = makeService({}); + await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3); + // Mojo is dispatch's job; Djibouti has not been loaded at yet. + expect(scanned).toEqual(['adama', 'dire']); + }); + + it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => { + const { svc } = makeService({ + adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], + }); + await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( + /Adama.*BK-200 \(5\/8 wagons loaded\)/s, + ); + }); + + it('names the resolution the operator has: load the rest, or cancel it', async () => { + const { svc } = makeService({ + adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], + }); + await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( + /customer fault: cancellation fee; EDR fault: no fee, rebookable/, + ); + }); + + it('scans nothing at the first checkpoint after the origin', async () => { + const { svc, scanned } = makeService({}); + await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1); + expect(scanned).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts new file mode 100644 index 000000000..b3e2c7678 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CancelTrainScheduleDto { + @ApiProperty({ + description: + 'Why this train is being cancelled. Shown on the schedule from then on, and to the staff who have to re-place its bookings.', + maxLength: 500, + }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 858d14c90..7312df2da 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2949,7 +2949,9 @@ export class TrainSchedulingService { // 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.assertNoPartiallyLoadedBookings(schedule, schedule.originStationId, { + action: 'dispatch', + }); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); @@ -3149,13 +3151,21 @@ export class TrainSchedulingService { * (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. + * Per-wagon loading gate: a booking with SOME wagons LOADED and SOME still + * PLANNED/RESERVED must resolve before the train leaves the yard it boards + * at — load the rest, or cancel the remainder (which shrinks the booking to + * its loaded wagons). Blocking beats silently unassigning: unassign would + * delete LOADED allocations and strand cargo physically on the train. + * + * Scoped to bookings BOARDING AT `boardingYardId`, so each yard answers only + * for its own cargo: a mid-corridor booking (A→B→C→D carrying a B→C load) is + * not due at A and must never hold the train there. */ - private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise { + private async assertNoPartiallyLoadedBookings( + schedule: TrainSchedule, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise { if (!schedule.trainSetId) return; const rows: Array<{ reference: string; loaded: string; total: string }> = await this.dataSource.query( @@ -3166,24 +3176,51 @@ export class TrainSchedulingService { 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 b.origin_yard_id = $2 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], + [schedule.trainSetId, boardingYardId], ); if (rows.length) { const detail = rows .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) .join(', '); + const where = context.yardLabel ? ` at ${context.yardLabel}` : ''; throw new BadRequestException( - `Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`, + `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + + `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` + + `rebookable) first: ${detail}`, ); } } + /** + * Mid-corridor twin of the dispatch gate. Logging a checkpoint at station N + * asserts the train has left every earlier stop, so each of those yards must + * have no half-loaded booking of its own left behind. The origin (seq 0) is + * skipped — dispatch already gated it — and the final station is included: + * arriving there still means the train left the stop before it. + */ + private async assertPassedYardsFullyLoaded( + schedule: TrainSchedule, + stations: Array<{ sequenceNo: number; yardId: string; label: string }>, + sequenceNo: number, + ): Promise { + const departed = stations.filter( + (st) => st.sequenceNo > 0 && st.sequenceNo < sequenceNo, + ); + for (const st of departed) { + await this.assertNoPartiallyLoadedBookings(schedule, st.yardId, { + action: 'record this checkpoint', + yardLabel: st.label, + }); + } + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -4744,6 +4781,12 @@ export class TrainSchedulingService { : TrainCheckpointKind.Passed); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt); + // Per-wagon loading, mid-corridor: recording THIS station means the train + // left the previous one, so every booking that boarded back there must be + // fully loaded or its remainder cancelled. The origin is covered by + // dispatch; here we answer for the stops between it and this one, so a + // skipped checkpoint log cannot smuggle an unresolved yard past the gate. + await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo); // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ @@ -5496,7 +5539,11 @@ export class TrainSchedulingService { return this.getTrainScheduleById(id); } - async cancelTrainSchedule(id: string) { + async cancelTrainSchedule( + id: string, + dto?: { reason?: string }, + userId?: string, + ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -5527,6 +5574,11 @@ export class TrainSchedulingService { TrainScheduleStatusEnum.Cancelled, now, ), + // Why the train died — read back by every view of the cancelled + // schedule, and by the staff who have to re-place its bookings. + cancellationReason: dto?.reason?.trim() || null, + cancelledAt: now, + cancelledByUserId: userId ?? null, }, manager, ); @@ -8020,6 +8072,8 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), status: schedule.status, bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + cancellationReason: schedule.cancellationReason ?? null, + cancelledAt: schedule.cancelledAt ?? null, maxWagons: schedule.maxWagons ?? 0, remainingWagons: Math.max( 0, @@ -9978,6 +10032,8 @@ export class TrainSchedulingService { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status, + cancellationReason: schedule.cancellationReason ?? null, + cancelledAt: schedule.cancelledAt ?? null, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, voyageNumber: schedule.voyageNumber ?? null, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 94da4837e..17fdbb6e4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -1159,6 +1159,18 @@ export default function TrainScheduleV2DetailPage() { } /> + {schedule.status === "CANCELLED" && schedule.cancellationReason ? ( + } + title="This schedule was cancelled" + > + {schedule.cancellationReason} + + ) : null} + {/* Ops signage: Train No. / Voyage No. / Direction read at a glance from across the room, so these stay large rather than folding into the numeric KpiStrip below. */} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 8041ad3ad..f9857fa5b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -17,6 +17,7 @@ import { Stack, Switch, Text, + Textarea, TextInput, ThemeIcon, } from "@mantine/core"; @@ -150,6 +151,9 @@ export default function TrainScheduleV2ListPage() { const [dispatchAt, setDispatchAt] = useState(null); // Cancelling is likewise irreversible — confirmed before the mutation fires. const [cancelTarget, setCancelTarget] = useState(null); + // Required: the reason is stored on the schedule and shown wherever the + // cancelled train appears, so staff downstream know why it died. + const [cancelReason, setCancelReason] = useState(""); const [editDateSchedule, setEditDateSchedule] = useState(null); const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); @@ -823,7 +827,10 @@ export default function TrainScheduleV2ListPage() { confirmed here rather than firing straight from the row menu. */} setCancelTarget(null)} + onClose={() => { + setCancelTarget(null); + setCancelReason(""); + }} title="Cancel this schedule?" centered radius="md" @@ -842,23 +849,43 @@ export default function TrainScheduleV2ListPage() { another schedule. ) : null} +