mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
intercity fix
This commit is contained in:
@@ -63,6 +63,7 @@ import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-c
|
||||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
@@ -123,11 +124,13 @@ import {
|
||||
sumWagonsRequired,
|
||||
type TrainLimitConfig,
|
||||
maxEdgeConsistUsage,
|
||||
perEdgeConsistUsage,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
import { CorridorBudget } from './corridor-capacity.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
@@ -1511,7 +1514,9 @@ export class TrainSchedulingService {
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
// Born SCHEDULED: there is no draft/finalize phase — a created train
|
||||
// is immediately visible and bookable to customers.
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
@@ -1615,7 +1620,11 @@ export class TrainSchedulingService {
|
||||
|
||||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
previewDto,
|
||||
limitLoco,
|
||||
schedule.maxWagons ?? undefined,
|
||||
);
|
||||
|
||||
// Callers that add bookings without hand-picking container slots (the
|
||||
// workspace "Add from pool" button, re-adding a removed booking) send no
|
||||
@@ -1769,20 +1778,38 @@ export class TrainSchedulingService {
|
||||
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
|
||||
// so their tare rides on top of the binding edge.
|
||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||
const edgeUsage = maxEdgeConsistUsage(
|
||||
wagonPlan,
|
||||
await this.stopYardsForSchedule(schedule),
|
||||
);
|
||||
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
|
||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||
const scheduleStops = await this.stopYardsForSchedule(schedule);
|
||||
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops);
|
||||
const stopLabels = await this.yardLabelMap(scheduleStops);
|
||||
// Each edge is its own consist — name EVERY leg that breaks the limit,
|
||||
// not just the heaviest figure, so staff see where along A→…→E it fails.
|
||||
const legName = (edge: number) =>
|
||||
scheduleStops.length > 2
|
||||
? `${stopLabels.get(scheduleStops[edge]) ?? scheduleStops[edge]} → ${
|
||||
stopLabels.get(scheduleStops[edge + 1]) ?? scheduleStops[edge + 1]
|
||||
}`
|
||||
: 'the route';
|
||||
const overweightLegs = perEdge
|
||||
.map((e) => ({
|
||||
edge: e.edge,
|
||||
grossWeightTons: roundTons(e.grossWeightTons + emptyConsistTareTons),
|
||||
}))
|
||||
.filter((e) => e.grossWeightTons > weightCapWithOverage);
|
||||
if (!dto.forceAssign && overweightLegs.length) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
|
||||
`Train set locomotives cannot pull the gross weight on ${overweightLegs
|
||||
.map((e) => `leg ${legName(e.edge)} (${e.grossWeightTons}T)`)
|
||||
.join(', ')} — limit ${roundTons(weightCapWithOverage)}T incl. tolerance`,
|
||||
);
|
||||
}
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
|
||||
const overlongLegs = perEdge
|
||||
.map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) }))
|
||||
.filter((e) => e.lengthMeters > lengthCapWithOverage);
|
||||
if (!dto.forceAssign && overlongLegs.length) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
|
||||
`Train set locomotives cannot support the train length on ${overlongLegs
|
||||
.map((e) => `leg ${legName(e.edge)} (${e.lengthMeters}m)`)
|
||||
.join(', ')} — limit ${roundTons(lengthCapWithOverage)}m incl. tolerance`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2296,6 +2323,12 @@ export class TrainSchedulingService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
// Schedules are born SCHEDULED now — finalize is a no-op for them so the
|
||||
// allocate wizard and the window auto-finalize keep working. The DRAFT
|
||||
// branch below only still runs for legacy rows.
|
||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Draft) {
|
||||
throw new BadRequestException('Only DRAFT schedules can be finalized');
|
||||
}
|
||||
@@ -4175,12 +4208,16 @@ export class TrainSchedulingService {
|
||||
wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
|
||||
).values(),
|
||||
];
|
||||
const stopLabelMap =
|
||||
stops.length > 2 ? await this.yardLabelMap(stops) : new Map<string, string>();
|
||||
const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId);
|
||||
pushLimit(
|
||||
validateMixedTrainLimitsPerEdge(
|
||||
wagonPlan,
|
||||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||||
trainLimits,
|
||||
stops,
|
||||
stopLabels,
|
||||
),
|
||||
);
|
||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||
@@ -4210,11 +4247,17 @@ export class TrainSchedulingService {
|
||||
);
|
||||
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||||
// above — the whole-route totals here are informational (summary) only. The
|
||||
// locomotive checks below also compare the heaviest single edge: a train is
|
||||
// never heavier than its heaviest leg, so disjoint legs must not be summed.
|
||||
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
|
||||
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
// locomotive checks below also compare per edge: a train is never heavier
|
||||
// than its heaviest leg, so disjoint legs must not be summed.
|
||||
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops);
|
||||
const maxEdgeGrossTons = roundTons(
|
||||
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
|
||||
);
|
||||
const maxEdgeLengthMeters = roundTons(
|
||||
Math.max(0, ...perEdgeUsage.map((e) => e.lengthMeters)),
|
||||
);
|
||||
const legName = (edge: number) =>
|
||||
stops.length > 2 ? `${stopLabels[edge]} → ${stopLabels[edge + 1]}` : 'the route';
|
||||
|
||||
let assignedLocomotives: Locomotive[] = [];
|
||||
if (targetScheduleId) {
|
||||
@@ -4234,16 +4277,29 @@ export class TrainSchedulingService {
|
||||
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
maxEdgeGrossTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
maxEdgeLengthMeters)
|
||||
) {
|
||||
pushLimit([
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
]);
|
||||
if (setLimits) {
|
||||
// Name every leg the set cannot pull — staff must see WHERE along the
|
||||
// corridor the train is too heavy/long, not just that it is somewhere.
|
||||
const weightCap =
|
||||
setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0);
|
||||
const lengthCap =
|
||||
setLimits.maxTrainLengthMeters +
|
||||
(Number(setLimits.overageToleranceMeters) || 0);
|
||||
const legIssues = perEdgeUsage.flatMap((e) => {
|
||||
const issues: string[] = [];
|
||||
if (roundTons(e.grossWeightTons) > weightCap) {
|
||||
issues.push(
|
||||
`Assigned locomotives cannot pull ${roundTons(e.grossWeightTons)}T gross on leg ${legName(e.edge)} (limit ${roundTons(weightCap)}T incl. tolerance)`,
|
||||
);
|
||||
}
|
||||
if (roundTons(e.lengthMeters) > lengthCap) {
|
||||
issues.push(
|
||||
`Assigned locomotives cannot support ${roundTons(e.lengthMeters)}m train length on leg ${legName(e.edge)} (limit ${roundTons(lengthCap)}m incl. tolerance)`,
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
});
|
||||
if (legIssues.length) pushLimit(legIssues);
|
||||
}
|
||||
} else {
|
||||
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
||||
@@ -4320,6 +4376,7 @@ export class TrainSchedulingService {
|
||||
maxWagonsPerTrain?: number;
|
||||
},
|
||||
locomotive?: LocomotiveLimits | null,
|
||||
builtWagonCount?: number,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
@@ -4362,10 +4419,18 @@ export class TrainSchedulingService {
|
||||
return {
|
||||
maxWeightTons: derived.maxWeightTons,
|
||||
maxLengthMeters: derived.maxLengthMeters,
|
||||
// A built train's own consist is the real capacity — the length-derived
|
||||
// slot count is only an estimate for trains with no wagons coupled yet.
|
||||
// Without this override, validation re-derives a DIFFERENT wagon cap
|
||||
// than the one the train was actually built with (e.g. a 54-wagon
|
||||
// consist rejected against a re-derived 53-slot cap that never matched
|
||||
// what staff physically coupled).
|
||||
maxWagonsPerTrain:
|
||||
dto?.maxWagonsPerTrain != null
|
||||
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
|
||||
: derived.maxWagonSlots,
|
||||
: builtWagonCount && builtWagonCount > 0
|
||||
? builtWagonCount
|
||||
: derived.maxWagonSlots,
|
||||
max20ftContainerWeightTons: this.positiveNumber(
|
||||
undefined,
|
||||
Number(row?.max20ftContainerWeightTons) ||
|
||||
@@ -6274,7 +6339,15 @@ export class TrainSchedulingService {
|
||||
route: { milestones: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
// Cargo relations feed effectiveWagonsRequired for legacy links whose
|
||||
// stored wagonsRequired is NULL — without them such a booking counts
|
||||
// as 1 wagon and per-leg occupancy under-reports.
|
||||
scheduleBookings: {
|
||||
booking: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
@@ -6332,12 +6405,54 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon slots still free for a leg of the schedule's corridor, per edge:
|
||||
* capacity minus every linked booking ON ITS OWN LEG — wagon sharing means a
|
||||
* booking alighting at a mid-stop frees its slots for the edges past it, so a
|
||||
* train full Mojo→Dire can still sell Dire→DCT. Works for any corridor length
|
||||
* (a→b→…→h). No leg given → the most open edge (can anything board at all?).
|
||||
*/
|
||||
private remainingWagonsForLeg(
|
||||
schedule: TrainSchedule,
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): number {
|
||||
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
|
||||
const budget = new CorridorBudget(stops, {
|
||||
wagons: Number(schedule.maxWagons ?? 0),
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
for (const sb of schedule.scheduleBookings ?? []) {
|
||||
if (!sb.booking) continue;
|
||||
budget.subtract(
|
||||
{
|
||||
wagons: this.effectiveWagonsRequired(sb.booking),
|
||||
weightTons: 0,
|
||||
lengthMeters: 0,
|
||||
},
|
||||
budget.legForYards(sb.booking.originYardId, sb.booking.destinationYardId),
|
||||
);
|
||||
}
|
||||
const leg =
|
||||
originYardId && destinationYardId
|
||||
? budget.legOf(originYardId, destinationYardId)
|
||||
: null;
|
||||
const remaining = leg ? budget.remainingFor(leg) : budget.maxRemaining();
|
||||
return Math.max(0, remaining.wagons);
|
||||
}
|
||||
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||||
return schedules.map((s) => ({
|
||||
...this.mapScheduleListItem(s),
|
||||
// Leg-aware: the list item's own remainingWagons is consist-based
|
||||
// (maxWagons − coupled wagons) and reads 0 on any fully-consisted train.
|
||||
remainingWagons: this.remainingWagonsForLeg(s, originYardId, destinationYardId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6383,8 +6498,16 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (schedules.length === 0) return { days: [] };
|
||||
|
||||
// Leg-aware: a train full on Mojo→Dire still sells Dire→DCT — gate on the
|
||||
// REQUESTED leg's free slots, not on how many wagons are coupled to the
|
||||
// consist (a fully-consisted train read 0 remaining and hid its days).
|
||||
const withCapacity = schedules.filter(
|
||||
(s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
|
||||
(s) =>
|
||||
this.remainingWagonsForLeg(
|
||||
s,
|
||||
input.originYardId,
|
||||
input.destinationYardId,
|
||||
) > 0,
|
||||
);
|
||||
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
|
||||
|
||||
@@ -6906,6 +7029,9 @@ export class TrainSchedulingService {
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
// Coupled wagons ride the whole corridor — they count on every leg.
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
@@ -7098,6 +7224,10 @@ export class TrainSchedulingService {
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: wagon.status,
|
||||
// Corridor span this slot rides (null = schedule endpoint) —
|
||||
// lets the UI compute per-leg utilization from real slots.
|
||||
boardYardId: wagon.boardYardId ?? null,
|
||||
alightYardId: wagon.alightYardId ?? null,
|
||||
physicalWagonId: frozenSlot
|
||||
? frozenSlot.physicalWagonId
|
||||
: wagon.physicalWagonId ?? null,
|
||||
@@ -7184,10 +7314,7 @@ export class TrainSchedulingService {
|
||||
sb.booking?.destinationYard?.label ??
|
||||
sb.booking?.destinationYard?.code ??
|
||||
null,
|
||||
wagonsRequired:
|
||||
sb.booking?.wagonsRequired != null
|
||||
? Number(sb.booking.wagonsRequired)
|
||||
: null,
|
||||
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
|
||||
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||
@@ -7212,6 +7339,15 @@ export class TrainSchedulingService {
|
||||
)
|
||||
: null;
|
||||
})(),
|
||||
// Length ceiling per leg, same shape as maxGrossWeightTons: the set's
|
||||
// most restrictive locomotive length plus its overage tolerance.
|
||||
maxLengthMeters: (() => {
|
||||
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
const cap =
|
||||
Number(setLimits?.maxTrainLengthMeters) +
|
||||
(Number(setLimits?.overageToleranceMeters) || 0);
|
||||
return setLimits && Number.isFinite(cap) ? roundTons(cap) : null;
|
||||
})(),
|
||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||
// it "historical" and skip re-pin affordances.
|
||||
@@ -7220,6 +7356,38 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking's wagon footprint with a computed fallback: rows linked by paths
|
||||
* that never stamped `wagonsRequired` (legacy allocate) read NULL, and every
|
||||
* occupancy consumer then counted them as 1 wagon — a 23-wagon booking showed
|
||||
* a near-empty leg. Falls back to the TEU/weight-derived count when the cargo
|
||||
* relations are loaded; a bare booking still degrades to 1.
|
||||
*/
|
||||
private effectiveWagonsRequired(booking: Booking): number {
|
||||
const stored = Number(booking.wagonsRequired);
|
||||
if (stored > 0) return Math.ceil(stored);
|
||||
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
||||
.map((wt) => Number(wt.capacityTons))
|
||||
.filter((c) => c > 0);
|
||||
const bulkCapacity =
|
||||
booking.freightType === 'BULK' && bulkCapacities.length
|
||||
? Math.max(...bulkCapacities)
|
||||
: undefined;
|
||||
return wagonsRequiredForBooking(booking, bulkCapacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* yardId → display label for error messages that name corridor legs. One
|
||||
* query; unknown ids fall back to the raw id so a message never goes blank.
|
||||
*/
|
||||
private async yardLabelMap(yardIds: string[]): Promise<Map<string, string>> {
|
||||
if (!yardIds.length) return new Map();
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: { id: In(yardIds) } });
|
||||
return new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
||||
}
|
||||
|
||||
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||
private mapScheduleStops(
|
||||
schedule: TrainSchedule,
|
||||
@@ -7310,6 +7478,7 @@ export class TrainSchedulingService {
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
schedule.maxWagons ?? undefined,
|
||||
);
|
||||
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
@@ -7439,6 +7608,7 @@ export class TrainSchedulingService {
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
schedule.maxWagons ?? undefined,
|
||||
);
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
@@ -7573,6 +7743,7 @@ export class TrainSchedulingService {
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
schedule.maxWagons ?? undefined,
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
@@ -8161,6 +8332,7 @@ export class TrainSchedulingService {
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
schedule.maxWagons ?? undefined,
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
|
||||
Reference in New Issue
Block a user