mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
Merge pull request #1437 from Tria-plc/freight_feature/usermanagement
fix issues
This commit is contained in:
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user