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