|
|
|
|
@@ -138,6 +138,12 @@ import {
|
|
|
|
|
import { CorridorBudget } from '../corridor-capacity.util';
|
|
|
|
|
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
|
|
|
|
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
|
|
|
|
import {
|
|
|
|
|
defaultPlannedWagonYards,
|
|
|
|
|
misalignedWagons,
|
|
|
|
|
type PlannedWagonYards,
|
|
|
|
|
scheduleYardOf,
|
|
|
|
|
} from '../utils/planned-wagon-yards.util';
|
|
|
|
|
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
|
|
|
|
import {
|
|
|
|
|
bookingCargoTons,
|
|
|
|
|
@@ -1557,6 +1563,7 @@ export class TrainSchedulingService {
|
|
|
|
|
// and yard follow the schedule.
|
|
|
|
|
let builtTrain: Train | null = null;
|
|
|
|
|
let locomotiveIds: string[];
|
|
|
|
|
let plannedWagonYards: PlannedWagonYards | null = null;
|
|
|
|
|
if (dto.trainId) {
|
|
|
|
|
builtTrain = await this.dataSource.getRepository(Train).findOne({
|
|
|
|
|
where: { id: dto.trainId },
|
|
|
|
|
@@ -1589,7 +1596,7 @@ export class TrainSchedulingService {
|
|
|
|
|
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await this.assertRouteCoversWagonYards(builtTrain, route);
|
|
|
|
|
plannedWagonYards = await this.defaultPlannedWagonYardsFor(builtTrain, route, scheduleWarnings);
|
|
|
|
|
const conflict = await this.findTrainRouteDayConflict(
|
|
|
|
|
builtTrain.id,
|
|
|
|
|
route.id,
|
|
|
|
|
@@ -1844,6 +1851,7 @@ export class TrainSchedulingService {
|
|
|
|
|
direction,
|
|
|
|
|
trainNumber: pairTrainNumber ?? undefined,
|
|
|
|
|
maxWagons,
|
|
|
|
|
plannedWagonYards,
|
|
|
|
|
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
|
|
|
|
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
|
|
|
|
|
...windowFields,
|
|
|
|
|
@@ -2742,6 +2750,9 @@ export class TrainSchedulingService {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// The yard plan this departure was SOLD against must match where the steel
|
|
|
|
|
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
|
|
|
|
|
await this.assertPlannedYardsAligned(schedule);
|
|
|
|
|
|
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
|
|
|
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
|
|
|
|
@@ -5288,15 +5299,30 @@ export class TrainSchedulingService {
|
|
|
|
|
return rows[0]?.train_id ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
|
|
|
|
|
private async plannedWagonYardsOf(
|
|
|
|
|
scheduleId: string | undefined,
|
|
|
|
|
manager?: EntityManager,
|
|
|
|
|
): Promise<PlannedWagonYards> {
|
|
|
|
|
if (!scheduleId) return {};
|
|
|
|
|
const runner = manager ?? this.dataSource;
|
|
|
|
|
const rows: { planned_wagon_yards: PlannedWagonYards | null }[] = await runner.query(
|
|
|
|
|
`SELECT planned_wagon_yards FROM freight.train_schedules WHERE id = $1`,
|
|
|
|
|
[scheduleId],
|
|
|
|
|
);
|
|
|
|
|
return rows[0]?.planned_wagon_yards ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async countFleetAvailability(
|
|
|
|
|
originYardId: string,
|
|
|
|
|
targetScheduleId?: string,
|
|
|
|
|
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
|
|
|
|
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
|
|
|
|
|
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
|
|
|
|
|
this.dataSource.getRepository(Wagon).find(),
|
|
|
|
|
this.dataSource.getRepository(WagonType).find(),
|
|
|
|
|
this.builtTrainIdOfSchedule(targetScheduleId),
|
|
|
|
|
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
|
|
|
|
this.plannedWagonYardsOf(targetScheduleId),
|
|
|
|
|
]);
|
|
|
|
|
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
|
|
|
|
const counts = new Map<string, { code: string; available: number }>();
|
|
|
|
|
@@ -5304,11 +5330,14 @@ export class TrainSchedulingService {
|
|
|
|
|
// A built consist spread across several yards can only offer, at each yard,
|
|
|
|
|
// the wagons standing there. A single-yard consist keeps the original
|
|
|
|
|
// behaviour: the whole train counts wherever it currently sits.
|
|
|
|
|
// Yards are the SCHEDULE's plan (falling back to the physical yard), so a
|
|
|
|
|
// departure sold from Dire counts its Dire wagons even while they still
|
|
|
|
|
// stand in Mojo — dispatch is what demands the two agree.
|
|
|
|
|
const consistYards = builtTrainId
|
|
|
|
|
? new Set(
|
|
|
|
|
wagons
|
|
|
|
|
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
|
|
|
|
.map((w) => w.currentYardId as string),
|
|
|
|
|
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plan, w))
|
|
|
|
|
.map((w) => scheduleYardOf(plan, w) as string),
|
|
|
|
|
)
|
|
|
|
|
: new Set<string>();
|
|
|
|
|
const consistIsSplit = consistYards.size > 1;
|
|
|
|
|
@@ -5320,7 +5349,7 @@ export class TrainSchedulingService {
|
|
|
|
|
// counted at the yard each wagon actually stands in.
|
|
|
|
|
if (builtTrainId) {
|
|
|
|
|
if (wagon.trainId !== builtTrainId) continue;
|
|
|
|
|
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
|
|
|
|
|
if (consistIsSplit && scheduleYardOf(plan, wagon) !== originYardId) continue;
|
|
|
|
|
} else {
|
|
|
|
|
// Schedule-scoped availability: pins held by OTHER schedules never
|
|
|
|
|
// consume a wagon here — the same physical wagon may serve the July 17
|
|
|
|
|
@@ -5491,6 +5520,7 @@ export class TrainSchedulingService {
|
|
|
|
|
|
|
|
|
|
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
|
|
|
|
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
|
|
|
|
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
|
|
|
|
|
|
|
|
|
|
const unpinnable = this.findUnpinnableWagonSlots(
|
|
|
|
|
planSlots,
|
|
|
|
|
@@ -5500,6 +5530,7 @@ export class TrainSchedulingService {
|
|
|
|
|
builtTrainId,
|
|
|
|
|
pinnedToScheduleIds,
|
|
|
|
|
stops,
|
|
|
|
|
plannedYards,
|
|
|
|
|
);
|
|
|
|
|
if (unpinnable.length) {
|
|
|
|
|
throw new BadRequestException({
|
|
|
|
|
@@ -5521,6 +5552,7 @@ export class TrainSchedulingService {
|
|
|
|
|
builtTrainId,
|
|
|
|
|
pinnedToScheduleIds,
|
|
|
|
|
reverseWagonOrder,
|
|
|
|
|
plannedYards,
|
|
|
|
|
);
|
|
|
|
|
if (!physical) continue;
|
|
|
|
|
|
|
|
|
|
@@ -5570,6 +5602,7 @@ export class TrainSchedulingService {
|
|
|
|
|
builtTrainId,
|
|
|
|
|
pinnedToScheduleIds,
|
|
|
|
|
stops,
|
|
|
|
|
targetSchedule?.plannedWagonYards ?? {},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -5602,6 +5635,7 @@ export class TrainSchedulingService {
|
|
|
|
|
builtTrainId: string | null = null,
|
|
|
|
|
pinnedToScheduleIds: Set<string> = new Set(),
|
|
|
|
|
stops: string[] = [],
|
|
|
|
|
plannedYards: PlannedWagonYards = {},
|
|
|
|
|
): string[] {
|
|
|
|
|
const violations: string[] = [];
|
|
|
|
|
// One physical wagon may serve several slots whose leg spans don't overlap
|
|
|
|
|
@@ -5620,6 +5654,8 @@ export class TrainSchedulingService {
|
|
|
|
|
span,
|
|
|
|
|
builtTrainId,
|
|
|
|
|
pinnedToScheduleIds,
|
|
|
|
|
false,
|
|
|
|
|
plannedYards,
|
|
|
|
|
);
|
|
|
|
|
if (!physical) {
|
|
|
|
|
violations.push(
|
|
|
|
|
@@ -5650,6 +5686,7 @@ export class TrainSchedulingService {
|
|
|
|
|
builtTrainId: string | null = null,
|
|
|
|
|
pinnedToScheduleIds: Set<string> = new Set(),
|
|
|
|
|
reverseWagonOrder = false,
|
|
|
|
|
plannedYards: PlannedWagonYards = {},
|
|
|
|
|
): Wagon | undefined {
|
|
|
|
|
// Free for this slot = no already-assigned span on this wagon overlaps the
|
|
|
|
|
// slot's own leg. Disjoint legs (alight before board) share the wagon.
|
|
|
|
|
@@ -5682,8 +5719,8 @@ export class TrainSchedulingService {
|
|
|
|
|
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
|
|
|
|
const consistYards = new Set(
|
|
|
|
|
wagons
|
|
|
|
|
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
|
|
|
|
.map((w) => w.currentYardId as string),
|
|
|
|
|
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
|
|
|
|
|
.map((w) => scheduleYardOf(plannedYards, w) as string),
|
|
|
|
|
);
|
|
|
|
|
// Split consist: a slot boarding at a given yard must take a wagon that
|
|
|
|
|
// physically stands there — the train cannot load a Mojo wagon at Dire.
|
|
|
|
|
@@ -5696,7 +5733,7 @@ export class TrainSchedulingService {
|
|
|
|
|
w.trainId === builtTrainId &&
|
|
|
|
|
w.wagonTypeId === slot.wagonTypeId &&
|
|
|
|
|
spanFree(w.id) &&
|
|
|
|
|
(!requiredYardId || w.currentYardId === requiredYardId),
|
|
|
|
|
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
|
|
|
|
|
)
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
|
|
|
|
@@ -5836,7 +5873,7 @@ export class TrainSchedulingService {
|
|
|
|
|
preloadedBuiltTrainId !== undefined
|
|
|
|
|
? preloadedBuiltTrainId
|
|
|
|
|
: await this.builtTrainIdOfSchedule(scheduleId);
|
|
|
|
|
if (builtTrainId) return this.builtTrainStock(builtTrainId);
|
|
|
|
|
if (builtTrainId) return this.builtTrainStock(builtTrainId, scheduleId);
|
|
|
|
|
|
|
|
|
|
const boardYardIds = [
|
|
|
|
|
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
|
|
|
|
|
@@ -5858,11 +5895,17 @@ export class TrainSchedulingService {
|
|
|
|
|
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
|
|
|
|
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
|
|
|
|
where: { trainId: builtTrainId },
|
|
|
|
|
relations: { wagonType: true },
|
|
|
|
|
});
|
|
|
|
|
private async builtTrainStock(
|
|
|
|
|
builtTrainId: string,
|
|
|
|
|
scheduleId?: string,
|
|
|
|
|
): Promise<WagonStock> {
|
|
|
|
|
const [wagons, plan] = await Promise.all([
|
|
|
|
|
this.dataSource.getRepository(Wagon).find({
|
|
|
|
|
where: { trainId: builtTrainId },
|
|
|
|
|
relations: { wagonType: true },
|
|
|
|
|
}),
|
|
|
|
|
this.plannedWagonYardsOf(scheduleId),
|
|
|
|
|
]);
|
|
|
|
|
const remainingByTypeId = new Map<string, number>();
|
|
|
|
|
const codesByTypeId = new Map<string, string>();
|
|
|
|
|
const byYardId = new Map<string, Map<string, number>>();
|
|
|
|
|
@@ -5872,10 +5915,12 @@ export class TrainSchedulingService {
|
|
|
|
|
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
|
|
|
|
);
|
|
|
|
|
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
|
|
|
|
if (wagon.currentYardId) {
|
|
|
|
|
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
|
|
|
|
|
// The schedule's own yard plan, not the physical yard — see plannedWagonYards.
|
|
|
|
|
const yardId = scheduleYardOf(plan, wagon);
|
|
|
|
|
if (yardId) {
|
|
|
|
|
const perType = byYardId.get(yardId) ?? new Map<string, number>();
|
|
|
|
|
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
|
|
|
|
byYardId.set(wagon.currentYardId, perType);
|
|
|
|
|
byYardId.set(yardId, perType);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Single-yard consist (the overwhelming majority): the whole train is
|
|
|
|
|
@@ -6215,17 +6260,22 @@ export class TrainSchedulingService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A built train's wagons may stand in several yards. The route must pass
|
|
|
|
|
* through every one of them as origin or an intermediate stop — never only
|
|
|
|
|
* as the final destination (the train has to pick the wagons up en route).
|
|
|
|
|
* Default yard plan for a schedule created from a built train: every wagon
|
|
|
|
|
* keeps the yard it physically stands in when that yard is a pickup stop of
|
|
|
|
|
* the route (origin or intermediate — never only the destination, the train
|
|
|
|
|
* has to collect it en route); the rest are planned at the origin and
|
|
|
|
|
* reported as a warning so staff can redistribute in the schedule-yards tab.
|
|
|
|
|
*/
|
|
|
|
|
private async assertRouteCoversWagonYards(train: Train, route: Route) {
|
|
|
|
|
private async defaultPlannedWagonYardsFor(
|
|
|
|
|
train: Train,
|
|
|
|
|
route: Route,
|
|
|
|
|
warnings: string[],
|
|
|
|
|
): Promise<PlannedWagonYards | null> {
|
|
|
|
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
|
|
|
|
where: { trainId: train.id },
|
|
|
|
|
select: { id: true, currentYardId: true },
|
|
|
|
|
select: { id: true, currentYardId: true, wagonNumber: true },
|
|
|
|
|
});
|
|
|
|
|
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
|
|
|
|
|
if (!wagonYards.length) return;
|
|
|
|
|
if (!wagons.length) return null;
|
|
|
|
|
|
|
|
|
|
const milestones = await this.dataSource
|
|
|
|
|
.getRepository(RouteMilestone)
|
|
|
|
|
@@ -6236,23 +6286,203 @@ export class TrainSchedulingService {
|
|
|
|
|
// Every stop except the last one is a pickup point.
|
|
|
|
|
const pickupYards = new Set(stops.slice(0, -1));
|
|
|
|
|
|
|
|
|
|
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
|
|
|
|
|
if (!uncovered.length) return;
|
|
|
|
|
const { plan, rehomed } = defaultPlannedWagonYards(wagons, pickupYards, route.originYardId);
|
|
|
|
|
if (rehomed.length) {
|
|
|
|
|
const labels = await this.yardLabelMap([
|
|
|
|
|
...new Set(rehomed.map((w) => w.currentYardId).filter((y): y is string => !!y)),
|
|
|
|
|
]);
|
|
|
|
|
const origin = labels.get(route.originYardId) ?? route.originYard?.label ?? 'the origin';
|
|
|
|
|
const where = [...new Set(rehomed.map((w) => (w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard')))].join(', ');
|
|
|
|
|
warnings.push(
|
|
|
|
|
`${rehomed.length} wagon(s) of train ${train.code} stand off this route (${where}) and were planned at ${origin}; ` +
|
|
|
|
|
'they must be moved there before dispatch — adjust in the schedule yards tab if they should board elsewhere',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return plan;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labels = await this.yardLabelMap(uncovered);
|
|
|
|
|
const destination = stops[stops.length - 1];
|
|
|
|
|
const detail = uncovered
|
|
|
|
|
.map((y) =>
|
|
|
|
|
y === destination
|
|
|
|
|
? `${labels.get(y) ?? y} (only as the destination)`
|
|
|
|
|
: `${labels.get(y) ?? y} (not on route)`,
|
|
|
|
|
)
|
|
|
|
|
/** Dispatch gate: every planned wagon must physically stand at its planned yard. */
|
|
|
|
|
private async assertPlannedYardsAligned(schedule: TrainSchedule) {
|
|
|
|
|
const builtTrainId = schedule.trainSet?.trainId;
|
|
|
|
|
if (!builtTrainId) return;
|
|
|
|
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
|
|
|
|
where: { trainId: builtTrainId },
|
|
|
|
|
select: { id: true, currentYardId: true, wagonNumber: true },
|
|
|
|
|
});
|
|
|
|
|
const off = misalignedWagons(schedule.plannedWagonYards, wagons);
|
|
|
|
|
if (!off.length) return;
|
|
|
|
|
const plan = schedule.plannedWagonYards ?? {};
|
|
|
|
|
const labels = await this.yardLabelMap([
|
|
|
|
|
...new Set(off.flatMap((w) => [plan[w.id], w.currentYardId]).filter((y): y is string => !!y)),
|
|
|
|
|
]);
|
|
|
|
|
const detail = off
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
.map((w) => `${w.wagonNumber} (planned ${labels.get(plan[w.id]) ?? plan[w.id]}, at ${w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard'})`)
|
|
|
|
|
.join(', ');
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
|
|
|
|
|
throw new ConflictException(
|
|
|
|
|
`Cannot dispatch: ${off.length} wagon(s) are not at the yard this schedule planned them for — ${detail}` +
|
|
|
|
|
(off.length > 5 ? ', …' : '') +
|
|
|
|
|
'. Move them in the train builder or re-plan them in the schedule yards tab.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Schedule-yards tab: where THIS departure boards each consist wagon vs
|
|
|
|
|
* where it physically stands, per stop totals, and which wagons are locked
|
|
|
|
|
* (already carrying this schedule's cargo).
|
|
|
|
|
*/
|
|
|
|
|
async getScheduleWagonYards(scheduleId: string) {
|
|
|
|
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
|
|
|
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
|
|
|
const builtTrain = schedule.trainSet?.train;
|
|
|
|
|
if (!builtTrain) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'This schedule was not created from a built train — it has no wagon yard plan',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const stops = this.mapScheduleStops(schedule);
|
|
|
|
|
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
|
|
|
|
const plan = schedule.plannedWagonYards ?? {};
|
|
|
|
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
|
|
|
|
where: { trainId: builtTrain.id },
|
|
|
|
|
relations: { wagonType: true, currentYard: true },
|
|
|
|
|
order: { sequenceNumber: 'ASC' },
|
|
|
|
|
});
|
|
|
|
|
const lockedIds = new Set(
|
|
|
|
|
(schedule.trainSet?.wagons ?? [])
|
|
|
|
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
|
|
|
|
.map((slot) => slot.physicalWagonId as string),
|
|
|
|
|
);
|
|
|
|
|
const offRouteYardIds = [
|
|
|
|
|
...new Set(
|
|
|
|
|
wagons
|
|
|
|
|
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
|
|
|
|
|
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
const labels = new Map([
|
|
|
|
|
...stops.map((s) => [s.yardId, s.label] as const),
|
|
|
|
|
...(await this.yardLabelMap(offRouteYardIds)),
|
|
|
|
|
]);
|
|
|
|
|
const editable =
|
|
|
|
|
schedule.status === TrainScheduleStatusEnum.Draft ||
|
|
|
|
|
schedule.status === TrainScheduleStatusEnum.Scheduled;
|
|
|
|
|
|
|
|
|
|
const rows = wagons.map((w) => {
|
|
|
|
|
const plannedYardId = scheduleYardOf(plan, w);
|
|
|
|
|
const locked = lockedIds.has(w.id);
|
|
|
|
|
return {
|
|
|
|
|
id: w.id,
|
|
|
|
|
wagonNumber: w.wagonNumber,
|
|
|
|
|
sequenceNumber: w.sequenceNumber,
|
|
|
|
|
wagonType: w.wagonType
|
|
|
|
|
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
|
|
|
|
|
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
|
|
|
|
|
physicalYardId: w.currentYardId,
|
|
|
|
|
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
|
|
|
|
plannedYardId,
|
|
|
|
|
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
|
|
|
|
aligned: plannedYardId === w.currentYardId,
|
|
|
|
|
locked,
|
|
|
|
|
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const perStop = stops.map((s) => ({
|
|
|
|
|
yardId: s.yardId,
|
|
|
|
|
label: s.label,
|
|
|
|
|
pickup: pickupYardIds.has(s.yardId),
|
|
|
|
|
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
|
|
|
|
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
|
|
|
|
}));
|
|
|
|
|
return {
|
|
|
|
|
scheduleId,
|
|
|
|
|
train: { id: builtTrain.id, code: builtTrain.code },
|
|
|
|
|
editable,
|
|
|
|
|
stops: perStop,
|
|
|
|
|
wagons: rows,
|
|
|
|
|
misaligned: rows.filter((r) => !r.aligned).length,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED
|
|
|
|
|
* schedules, only the train's own wagons, only pickup stops of the route,
|
|
|
|
|
* never a wagon already carrying this schedule's cargo. Physical yards are
|
|
|
|
|
* untouched — the train builder owns those.
|
|
|
|
|
*/
|
|
|
|
|
async updateScheduleWagonYards(
|
|
|
|
|
scheduleId: string,
|
|
|
|
|
moves: Array<{ wagonId: string; yardId: string }>,
|
|
|
|
|
) {
|
|
|
|
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
|
|
|
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
|
|
|
const builtTrain = schedule.trainSet?.train;
|
|
|
|
|
if (!builtTrain) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'This schedule was not created from a built train — it has no wagon yard plan',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
|
|
|
|
schedule.status !== TrainScheduleStatusEnum.Scheduled
|
|
|
|
|
) {
|
|
|
|
|
throw new ConflictException(
|
|
|
|
|
`Wagon yards can only be re-planned before departure (schedule is ${schedule.status})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const stops = this.mapScheduleStops(schedule);
|
|
|
|
|
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
|
|
|
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
|
|
|
|
where: { trainId: builtTrain.id },
|
|
|
|
|
select: { id: true, currentYardId: true, wagonNumber: true },
|
|
|
|
|
});
|
|
|
|
|
const wagonById = new Map(wagons.map((w) => [w.id, w]));
|
|
|
|
|
const lockedIds = new Set(
|
|
|
|
|
(schedule.trainSet?.wagons ?? [])
|
|
|
|
|
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
|
|
|
|
.map((slot) => slot.physicalWagonId as string),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
|
|
|
|
|
for (const move of moves) {
|
|
|
|
|
const wagon = wagonById.get(move.wagonId);
|
|
|
|
|
if (!wagon) {
|
|
|
|
|
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
|
|
|
|
|
}
|
|
|
|
|
if (!pickupYardIds.has(move.yardId)) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) {
|
|
|
|
|
throw new ConflictException(
|
|
|
|
|
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
plan[wagon.id] = move.yardId;
|
|
|
|
|
}
|
|
|
|
|
await this.dataSource
|
|
|
|
|
.getRepository(TrainSchedule)
|
|
|
|
|
.update(scheduleId, { plannedWagonYards: plan });
|
|
|
|
|
|
|
|
|
|
// ponytail: per-stop over-booking check counts bookings boarding at the
|
|
|
|
|
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
|
|
|
|
// a gate; switch to CorridorBudget per stop if staff need exact numbers.
|
|
|
|
|
const warnings: string[] = [];
|
|
|
|
|
for (const stop of stops.slice(0, -1)) {
|
|
|
|
|
const planned = wagons.filter((w) => scheduleYardOf(plan, w) === stop.yardId).length;
|
|
|
|
|
const booked = (schedule.scheduleBookings ?? [])
|
|
|
|
|
.filter((sb) => sb.booking?.originYardId === stop.yardId)
|
|
|
|
|
.reduce((sum, sb) => sum + (sb.booking ? this.effectiveWagonsRequired(sb.booking) : 0), 0);
|
|
|
|
|
if (booked > planned) {
|
|
|
|
|
warnings.push(
|
|
|
|
|
`${stop.label}: bookings boarding here need ${booked} wagon(s) but only ${planned} are planned at this yard`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ...(await this.getScheduleWagonYards(scheduleId)), warnings };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getSchedulableRoute(routeId: string) {
|
|
|
|
|
const route = await this.dataSource.getRepository(Route).findOne({
|
|
|
|
|
where: { id: routeId },
|
|
|
|
|
|