intercity fix

This commit is contained in:
Marshal
2026-07-31 10:50:16 +00:00
parent 74b7ee81fc
commit a64af98205
17 changed files with 1174 additions and 182 deletions

View File

@@ -1,15 +1,17 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
/**
* History row for a consist adjustment made from a schedule: staff coupled a
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
* e.g. trimming free wagons whose tare pushed gross weight over the
* locomotives' pull limit. Plain columns (no FK relations) so the history
* survives the wagon or train being deleted later.
* wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
* under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
* schedule's built train. `yardId` records WHERE it happened: the origin yard
* before departure, or the mid-route stop the train was standing at. Plain
* columns (no FK relations) so the history survives the wagon or train being
* deleted later.
*/
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
@Index(['trainScheduleId'])
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
adjustedByUserId!: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}

View File

@@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
// 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
// the time it departs — and return it to the waiting pool as PAID.
if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
await this.dataSource.getRepository(Booking).update(bookingId, {
trainScheduleId: null,
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
} as never);
this.logger.log(
`[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
);
void this.completeTrackingMilestones(bookingId, [
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
return;
}
if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
@@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return;
}
// Manual placement of an ALREADY-PAID intercity booking: payment landed
// earlier (and unpinned it back to the pool) — staff are now choosing its
// train, so link directly. No new pay window; wagon assignment stays with
// staff in the workspace.
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'paid');
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
@@ -3363,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
);
this.notifier.secured(booking, reason, scheduleId);
void this.triggerWagonAllocation(scheduleId);
// Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto
// wagon assignment is for the import/export batch flow only.
if (booking.tradeDirection !== 'DOMESTIC') {
void this.triggerWagonAllocation(scheduleId);
}
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.

View File

@@ -1,5 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator';
export class ConsistWagonSwitchDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' })
@IsUUID()
fromWagonId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' })
@IsUUID()
toWagonId!: string;
}
export class AdjustScheduleConsistDto {
@ApiPropertyOptional({
@@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto {
@IsArray()
@IsUUID('all', { each: true })
removeWagonIds?: string[];
@ApiPropertyOptional({
type: [ConsistWagonSwitchDto],
description:
"Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ConsistWagonSwitchDto)
switches?: ConsistWagonSwitchDto[];
}

View File

@@ -330,8 +330,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
// PAID = customer paid but staff have not placed it on a train yet
// (intercity allocation is manual) — it stays in the pool until they do.
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
`((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
@@ -382,8 +384,12 @@ export class IntercityService {
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
// Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
// awaiting manual placement) links straight onto the chosen train.
const readyStatuses = booking.isGovernment
? ['APPROVED']
: ['FULLY_EXECUTED', 'PAID'];
if (!readyStatuses.includes(booking.status)) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {

View File

@@ -195,6 +195,16 @@ export class TrainSchedulingController {
);
}
@Get("schedules/:id/history")
@TrainSchedulingView()
@ApiOperation({
summary:
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
})
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleHistory(id);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Contract } from '../contracts/entities/contract.entity';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -1249,8 +1250,31 @@ export class TrainSchedulingService {
};
}
/**
* Limits for a preview aimed at an EXISTING schedule must be the schedule's
* own: its locomotive set and its built-consist wagon cap. Resolving from
* the dto alone re-derived the global wagon cap (53) and rejected a
* physically-coupled 54-wagon train the assign path would accept.
*/
private async resolvePreviewLimitConfig(dto: {
targetScheduleId?: string;
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}): Promise<Required<TrainLimitConfig>> {
const target = dto.targetScheduleId
? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId)
: null;
if (!target) return this.resolveTrainLimitConfig(dto);
return this.resolveTrainLimitConfig(
dto,
combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)),
target.maxWagons ?? undefined,
);
}
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
@@ -1265,7 +1289,7 @@ export class TrainSchedulingService {
}
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
@@ -1280,7 +1304,7 @@ export class TrainSchedulingService {
}
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
@@ -4657,16 +4681,29 @@ export class TrainSchedulingService {
* schedule. Used to guard consist trims — the Wagon entity itself carries no
* schedule-occupancy state anymore.
*/
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
/**
* Physical wagons pinned to any live run's slot. `excludeTrainId` drops the
* pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g.
* the paired return leg): a consist edit is an edit of the TRAIN, sibling
* runs ride whatever it is composed of and their pins are re-pointed by the
* edit itself. Only pins held by live schedules of OTHER trains block it.
*/
private async wagonIdsPinnedToLiveSchedules(
manager?: EntityManager,
excludeTrainId?: string,
): Promise<Set<string>> {
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT DISTINCT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
JOIN freight.train_sets tset ON tset.id = tsw.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`,
AND tsw.physical_wagon_id IS NOT NULL
AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`,
[excludeTrainId ?? null],
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
@@ -5711,6 +5748,70 @@ export class TrainSchedulingService {
});
}
/**
* Where consist work can physically happen right now. Before departure it is
* the built train's own yard. After dispatch it is the route stop the train
* is STANDING AT per its latest checkpoint — null while rolling between
* stops or when the last checkpoint is off-route, and consist work is closed
* there. Arrived/cancelled schedules always return null (history only).
*/
private async currentConsistYardId(
schedule: TrainSchedule,
): Promise<string | null> {
if (
schedule.status === TrainScheduleStatusEnum.Draft ||
schedule.status === TrainScheduleStatusEnum.Scheduled
) {
return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId;
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null;
const rows: Array<{ yard_id: string | null }> = await this.dataSource.query(
`SELECT yard_id
FROM freight.train_checkpoint_events
WHERE train_schedule_id = $1
ORDER BY occurred_at DESC, created_at DESC
LIMIT 1`,
[schedule.id],
);
const yardId = rows[0]?.yard_id ?? null;
if (!yardId) return null;
return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId)
? yardId
: null;
}
/**
* Physical wagons whose cargo still RIDES beyond the given stop: any
* allocation whose booking alights strictly after it. Cargo whose
* destination is this stop (or an earlier one) has been offloaded here and
* no longer blocks its wagon — that wagon may be trimmed or switched away.
* Before departure the stop is the origin, so every allocated wagon counts
* as aboard — one rule covers both phases. Unknown destinations and
* off-route stops stay conservative (aboard).
*/
// ponytail: trusts booking.destinationYardId, not a physical unload
// confirmation — if staff trim before actually unloading, the cargo strands.
// Wire the journey unload flag in if that ever bites.
private wagonIdsWithCargoBeyond(
schedule: TrainSchedule,
atYardId: string | null,
): Set<string> {
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
const atIdx = atYardId ? stops.indexOf(atYardId) : -1;
const aboard = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue;
const ridesOn = (slot.allocations ?? []).some((allocation) => {
const destination = allocation.booking?.destinationYardId;
const destIdx = destination ? stops.indexOf(destination) : -1;
if (destIdx < 0 || atIdx < 0) return true;
return destIdx > atIdx;
});
if (ridesOn) aboard.add(slot.physicalWagonId);
}
return aboard;
}
/**
* Consist snapshot for the adjust-consist UI: the built train's wagons with
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
@@ -5727,32 +5828,41 @@ export class TrainSchedulingService {
);
}
// Where the train stands right now — the origin yard before departure, the
// checkpoint stop after it. Null = rolling; the consist is view-only then.
const currentYardId = await this.currentConsistYardId(schedule);
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const addableWagons = await this.dataSource.getRepository(Wagon).find({
where: {
trainId: IsNull(),
status: WagonStatus.Available,
currentYardId: builtTrain.currentYardId ?? undefined,
},
relations: { wagonType: true },
order: { wagonNumber: 'ASC' },
});
const addableWagons = currentYardId
? await this.dataSource.getRepository(Wagon).find({
where: {
trainId: IsNull(),
status: WagonStatus.Available,
currentYardId,
},
relations: { wagonType: true },
order: { wagonNumber: 'ASC' },
})
: [];
const adjustments = await this.dataSource
.getRepository(ScheduleWagonAdjustmentLog)
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
// Slots with cargo aboard — their physical wagons are "loaded" and can
// never be trimmed.
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
// Slots whose cargo still rides beyond the current stop — those wagons
// cannot be trimmed, only switched. Cargo offloaded at this stop (or
// earlier) has released its wagon.
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
// Only OTHER trains' pins block edits here — this train's own schedules
// (incl. the paired return run) have their pins managed by the edit itself
// (removal clears, switch re-points).
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
undefined,
builtTrain.id,
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -5817,12 +5927,23 @@ export class TrainSchedulingService {
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
}
: null,
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})),
wagons: wagons.map((wagon) => {
const loaded = loadedWagonIds.has(wagon.id);
const pinnedElsewhere = pinnedToLiveIds.has(wagon.id);
return {
...mapWagon(wagon),
loaded,
removable: !pinnedElsewhere && !loaded,
// A loaded wagon can't leave, but its SLOT can change wagon: switch
// moves the cargo allocations onto a same-type replacement.
switchable: !pinnedElsewhere,
blockReason: pinnedElsewhere
? 'Pinned by another live schedule'
: loaded
? 'Cargo aboard rides beyond this stop — switch it instead'
: null,
};
}),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
id: log.id,
@@ -5830,9 +5951,26 @@ export class TrainSchedulingService {
wagonId: log.wagonId,
wagonNumber: log.wagonNumber,
adjustedByUserId: log.adjustedByUserId,
yardId: log.yardId ?? null,
occurredAt: log.occurredAt,
})),
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
// Editable before departure, and after it whenever the train is standing
// at a route stop (mid-route wagon work at station B); frozen while
// rolling and once arrived/cancelled.
editable:
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
(schedule.status === TrainScheduleStatusEnum.Dispatched &&
currentYardId != null),
currentStop: currentYardId
? {
yardId: currentYardId,
label:
this.mapScheduleStops(schedule).find(
(s) => s.yardId === currentYardId,
)?.label ?? currentYardId,
isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched,
}
: null,
};
}
@@ -5851,19 +5989,38 @@ export class TrainSchedulingService {
) {
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
if (!addWagonIds.length && !removeWagonIds.length) {
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
const switches = dto.switches ?? [];
if (!addWagonIds.length && !removeWagonIds.length && !switches.length) {
throw new BadRequestException(
'Nothing to adjust — pass wagons to add, remove and/or switch',
);
}
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
if (overlap.length) {
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
const switchFromIds = switches.map((s) => s.fromWagonId);
const switchToIds = switches.map((s) => s.toWagonId);
const touched = new Map<string, number>();
for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) {
touched.set(id, (touched.get(id) ?? 0) + 1);
}
if ([...touched.values()].some((count) => count > 1)) {
throw new BadRequestException(
'Each wagon may appear once per adjustment — not in two lists or two switches',
);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
// Consist edits are open before departure, and after it whenever the train
// is STANDING AT a route stop (checkpointed): that is exactly the "switch
// wagons at station B" window. Rolling between stops → frozen.
const currentYardId = await this.currentConsistYardId(schedule);
const editableStatus =
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
schedule.status === TrainScheduleStatusEnum.Dispatched;
if (!editableStatus || !currentYardId) {
throw new BadRequestException(
'The consist is frozen once the train is dispatched — adjust before departure',
schedule.status === TrainScheduleStatusEnum.Dispatched
? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)'
: 'The consist can no longer be adjusted — the run is over',
);
}
const builtTrainRef = schedule.trainSet?.train;
@@ -5872,11 +6029,9 @@ export class TrainSchedulingService {
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
// Wagons whose cargo still rides beyond the current stop: never removable,
// but switchable — the replacement inherits the slot, cargo included.
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
@@ -5899,25 +6054,41 @@ export class TrainSchedulingService {
});
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
// --- validate removals: coupled, cargo offloaded, no foreign pin ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
manager,
train.id,
);
// Every live train set of THIS built train (this run + siblings, e.g.
// the paired return leg) — their pins follow the consist edit.
const ownSetIds = (
await manager.getRepository(TrainSet).find({
where: { trainId: train.id },
select: { id: true },
})
).map((set) => set.id);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
if (loadedWagonIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
`Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`,
);
}
if (pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`,
);
}
removed.push(wagon);
}
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
// Shared gate for every incoming wagon (couple or switch replacement):
// AVAILABLE, loose, and standing where the train stands right now.
const lockIncomingWagon = async (wagonId: string): Promise<Wagon> => {
// No `relations` on this query: Postgres refuses FOR UPDATE through the
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
// applied to the nullable side of an outer join"). Lock the row alone,
@@ -5935,21 +6106,57 @@ export class TrainSchedulingService {
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
);
}
if (wagon.currentYardId !== train.currentYardId) {
if (wagon.currentYardId !== currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
`Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`,
);
}
wagon.wagonType =
(await manager
.getRepository(WagonType)
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
added.push(wagon);
return wagon;
};
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
added.push(await lockIncomingWagon(wagonId));
}
// --- headroom check (only additions can push the train over a cap) ---
// --- validate switches: outgoing coupled + not foreign-pinned; the
// replacement passes the incoming gate AND matches the wagon type, so
// the slot's cargo (weight, TEU geometry) rides it unchanged ---
const switchPairs: Array<{ from: Wagon; to: Wagon }> = [];
for (const { fromWagonId, toWagonId } of switches) {
const from = consistById.get(fromWagonId);
if (!from) {
throw new NotFoundException(
`Wagon ${fromWagonId} is not coupled to train ${train.code}`,
);
}
if (pinnedToLiveIds.has(from.id)) {
throw new ConflictException(
`Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`,
);
}
const to = await lockIncomingWagon(toWagonId);
if (to.wagonTypeId !== from.wagonTypeId) {
throw new BadRequestException(
`Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`,
);
}
switchPairs.push({ from, to });
}
// --- headroom check (only additions can push the train over a cap;
// switches are same-type and cancel out, but are computed honestly) ---
const removedIds = new Set(removed.map((w) => w.id));
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
const switchedFromIds = new Set(switchPairs.map((p) => p.from.id));
const finalConsist = [
...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)),
...added,
...switchPairs.map((p) => p.to),
];
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
@@ -5967,21 +6174,72 @@ export class TrainSchedulingService {
);
}
// --- apply: detach trims, couple additions, compact the sequence ---
// --- apply: detach trims, couple additions, swap switches, compact ---
// A wagon leaving the train stands wherever the train stands — stamping
// the stop yard is what makes it findable (and re-couplable) at B.
const detachPatch = {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
trainSetWagonId: null,
currentTrainScheduleId: null,
currentYardId,
};
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
}
const remaining = consist.filter((w) => !removedIds.has(w.id));
for (let i = 0; i < remaining.length; i++) {
if (remaining[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
if (removed.length && ownSetIds.length) {
// This train's own pins (all its runs) on trimmed wagons are stale —
// clear them so the freed wagon isn't still claimed by slots it left.
await manager
.getRepository(TrainSetWagon)
.update(
{ trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) },
{ physicalWagonId: null },
);
}
// Switches: the replacement takes the outgoing wagon's position AND its
// slot pins, so every cargo allocation now rides the new wagon. The
// outgoing wagon is left standing at the stop.
for (const { from, to } of switchPairs) {
const slots = ownSetIds.length
? await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: In(ownSetIds), physicalWagonId: from.id },
})
: [];
for (const slot of slots) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { physicalWagonId: to.id });
}
const ownSlot =
slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0];
await manager.getRepository(Wagon).update(to.id, {
trainId: train.id,
sequenceNumber: from.sequenceNumber,
status: WagonStatus.Assigned,
trainSetWagonId: ownSlot?.id ?? null,
currentTrainScheduleId: from.currentTrainScheduleId ?? null,
});
// Mirror on the in-memory row — the compaction below sorts by it.
to.sequenceNumber = from.sequenceNumber;
await manager.getRepository(Wagon).update(from.id, detachPatch);
}
const remaining = consist.filter(
(w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id),
);
const switchedIn = switchPairs.map((p) => p.to);
const compacted = [...remaining, ...switchedIn].sort(
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
);
for (let i = 0; i < compacted.length; i++) {
if (compacted[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 });
}
}
let sequence = remaining.length;
let sequence = compacted.length;
for (const wagon of added) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
@@ -6000,16 +6258,31 @@ export class TrainSchedulingService {
const now = new Date();
await logRepo.save(
[
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
].map(({ action, wagon }) =>
...removed.map((wagon) => ({
action: 'REMOVE' as const,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
})),
...added.map((wagon) => ({
action: 'ADD' as const,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
})),
...switchPairs.map(({ from, to }) => ({
action: 'SWITCH' as const,
wagonId: to.id,
// varchar(50) — two long wagon numbers could overflow the column.
wagonNumber: `${from.wagonNumber}${to.wagonNumber}`.slice(0, 50),
})),
].map((entry) =>
logRepo.create({
trainScheduleId: scheduleId,
trainId: train.id,
action,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
action: entry.action,
wagonId: entry.wagonId,
wagonNumber: entry.wagonNumber,
adjustedByUserId: userId ?? null,
yardId: currentYardId,
occurredAt: now,
}),
),
@@ -6049,6 +6322,72 @@ export class TrainSchedulingService {
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
}
/**
* Unified change history for the schedule detail "History" tab: wagon
* consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened
* at) merged with booking composition removals, newest first. Actor resolves
* through iam.users; rows survive wagon/train deletion (log tables carry
* plain columns, no FKs).
*/
async getScheduleHistory(scheduleId: string) {
type HistoryRow = {
id: string;
kind: 'WAGON' | 'BOOKING';
action: string;
subject: string | null;
yardLabel: string | null;
actor: string | null;
note: string | null;
occurredAt: Date;
};
const wagonRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT l.id,
l.action,
l.wagon_number AS "subject",
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
WHERE l.train_schedule_id = $1
AND l.deleted_at IS NULL
ORDER BY l.occurred_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
...r,
kind: 'WAGON' as const,
note: null,
}));
const bookingRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT r.id,
r.booking_reference AS "subject",
r.notes AS "note",
COALESCE(u.username, u.email) AS "actor",
r.removed_at AS "occurredAt"
FROM freight.train_composition_removal_logs r
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
WHERE r.schedule_id = $1
AND r.deleted_at IS NULL
ORDER BY r.removed_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
...r,
kind: 'BOOKING' as const,
action: 'BOOKING_REMOVED',
yardLabel: null,
}));
return [...wagonRows, ...bookingRows].sort(
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
}
/**
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
@@ -6938,11 +7277,21 @@ export class TrainSchedulingService {
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// Booking has no ORM relation to Contract (FK only) — fetched separately
// by id so the "on this train" cards can show the contract reference.
const contractIds = [
...new Set(
(schedule.scheduleBookings ?? [])
.map((sb) => sb.booking?.contractId)
.filter((id): id is string => Boolean(id)),
),
];
// All independent lookups fired at once — they used to run one after
// another, stacking round-trips onto every detail request.
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
// number the locomotive actually hauls against its pull limit.
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] =
await Promise.all([
this.loadWagonTareDims(),
requiresLoadingConfirmation
@@ -6972,7 +7321,13 @@ export class TrainSchedulingService {
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
})
: [],
contractIds.length
? this.dataSource
.getRepository(Contract)
.find({ where: { id: In(contractIds) }, select: { id: true, reference: true } })
: [],
]);
const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference]));
const loadingConfirmed = requiresLoadingConfirmation
? Boolean(importOp?.loadedOnTrainAt)
: true;
@@ -7315,6 +7670,10 @@ export class TrainSchedulingService {
sb.booking?.destinationYard?.code ??
null,
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
contractReference:
(sb.booking?.contractId
? contractReferenceById.get(sb.booking.contractId)
: null) ?? null,
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the