Merge pull request #1437 from Tria-plc/freight_feature/usermanagement

fix issues
This commit is contained in:
marshal
2026-08-28 13:35:20 +03:00
committed by GitHub
14 changed files with 435 additions and 49 deletions

View File

@@ -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<void> {
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<void> {
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"
`);
}
}

View File

@@ -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) {

View File

@@ -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[];
}

View File

@@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => {
maxWagons?: number;
routeStops?: string[];
yardCountries?: Record<string, string>;
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

View File

@@ -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<CorridorBudget> {
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);

View File

@@ -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);
}
}

View File

@@ -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 = <schedule origin>`.
* 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<unknown> };
assertNoPartiallyLoadedBookings(
schedule: unknown,
boardingYardId: string,
context: { action: string; yardLabel?: string },
): Promise<void>;
assertPassedYardsFullyLoaded(
schedule: unknown,
stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
sequenceNo: number,
): Promise<void>;
};
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<string, Array<Record<string, string>>>) => {
const scanned: string[] = [];
const svc = Object.create(TrainSchedulingService.prototype) as {
dataSource: { query: (sql: string, params: unknown[]) => Promise<unknown> };
assertPassedYardsFullyLoaded(
schedule: unknown,
stations: typeof STATIONS,
sequenceNo: number,
): Promise<void>;
};
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([]);
});
});

View File

@@ -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;
}

View File

@@ -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<void> {
private async assertNoPartiallyLoadedBookings(
schedule: TrainSchedule,
boardingYardId: string,
context: { action: string; yardLabel?: string },
): Promise<void> {
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<void> {
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,

View File

@@ -1159,6 +1159,18 @@ export default function TrainScheduleV2DetailPage() {
}
/>
{schedule.status === "CANCELLED" && schedule.cancellationReason ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="This schedule was cancelled"
>
{schedule.cancellationReason}
</Alert>
) : 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. */}

View File

@@ -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<Date | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] = useState<TrainScheduleListItem | null>(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<TrainScheduleListItem | null>(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. */}
<Modal
opened={cancelTarget != null}
onClose={() => setCancelTarget(null)}
onClose={() => {
setCancelTarget(null);
setCancelReason("");
}}
title="Cancel this schedule?"
centered
radius="md"
@@ -842,23 +849,43 @@ export default function TrainScheduleV2ListPage() {
another schedule.
</Text>
) : null}
<Textarea
label="Reason for cancelling"
placeholder="e.g. locomotive failure, track closure, insufficient cargo"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
autosize
minRows={2}
maxLength={500}
required
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setCancelTarget(null)}>
<Button
variant="default"
onClick={() => {
setCancelTarget(null);
setCancelReason("");
}}
>
Keep schedule
</Button>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
disabled={!cancelReason.trim()}
onClick={async () => {
if (!cancelTarget) return;
if (!cancelTarget || !cancelReason.trim()) return;
try {
await cancel.mutateAsync({
id: cancelTarget.id,
freightType: cancelTarget.freightType ?? "CONTAINER",
reason: cancelReason.trim(),
});
toast({ title: "Schedule cancelled" });
setCancelTarget(null);
setCancelReason("");
void schedulesQuery.refetch();
} catch (err) {
toast({
@@ -929,6 +956,11 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
</Text>
<StatusPill status={schedule.status} />
</Group>
{schedule.status === "CANCELLED" && schedule.cancellationReason ? (
<Text size="xs" c="red.7" lh={1.2} truncate title={schedule.cancellationReason}>
{schedule.cancellationReason}
</Text>
) : null}
</Stack>
);
}
@@ -1041,6 +1073,15 @@ function ScheduleCard({
<MetricChip value={schedule.bookingsCount} label="bkg" />
</Group>
{schedule.status === "CANCELLED" && schedule.cancellationReason ? (
<Text size="xs" c="red.7" lineClamp={2}>
<Text span fw={600}>
Cancelled:
</Text>{" "}
{schedule.cancellationReason}
</Text>
) : null}
<Group gap="xs" wrap="nowrap">
<Button
variant="light"

View File

@@ -1044,13 +1044,17 @@ export const api = {
),
cancelSchedule: endpoint<
{ id: string; freightType?: FreightType },
{ id: string; freightType?: FreightType; reason: string },
TrainScheduleDetail
>(
"train-scheduling",
"cancel-schedule",
({ id, freightType }) =>
trainSchedulingService.cancelSchedule(id, freightType ?? "CONTAINER"),
({ id, freightType, reason }) =>
trainSchedulingService.cancelSchedule(
id,
freightType ?? "CONTAINER",
reason,
),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),

View File

@@ -801,12 +801,13 @@ export const trainSchedulingService = {
cancelSchedule: async (
id: string,
freightType: FreightType = "CONTAINER",
reason?: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
pathsFor(
freightType === "MIXED" ? undefined : freightType,
).CANCEL_SCHEDULE(id),
{},
{ reason },
);
return unwrap(response.data);
},

View File

@@ -287,6 +287,9 @@ export interface BookableSchedule {
freightType?: FreightType | null;
status: TrainScheduleStatus | string;
bookingWindowStatus: "OPEN" | "FULL" | "CLOSED" | string;
/** Why the schedule was cancelled — null unless status is CANCELLED. */
cancellationReason?: string | null;
cancelledAt?: string | null;
maxWagons: number;
remainingWagons: number;
locomotive: { id: string; code: string; name?: string | null } | null;
@@ -637,6 +640,9 @@ export interface TrainScheduleDetail {
id: string;
reference?: string | null;
status: TrainScheduleStatus | string;
/** Why the schedule was cancelled — null unless status is CANCELLED. */
cancellationReason?: string | null;
cancelledAt?: string | null;
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;