fix issues

This commit is contained in:
Marshal
2026-08-28 10:34:02 +00:00
parent ba56974e32
commit 3015de7508
14 changed files with 435 additions and 49 deletions

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,
@@ -4652,6 +4689,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({
@@ -5404,7 +5447,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`);
@@ -5435,6 +5482,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,
);
@@ -7928,6 +7980,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,
@@ -9886,6 +9940,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,