fix(train-scheduling): move loose wagons and locomotives on train merge

This commit is contained in:
Marshal
2026-08-12 11:23:10 +00:00
parent 3dbda745dd
commit f882104ed3
4 changed files with 123 additions and 45 deletions

View File

@@ -330,6 +330,8 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
interface BookingWindowRow {
schedule_id: string;
reference: string | null;
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
train_number: string | null;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
@@ -5872,6 +5874,7 @@ export class TrainSchedulingService {
wagonSlots: schedule.trainSet?.wagons,
storedWagonCount: schedule.trainSet?.wagonCount,
scheduleBookings: schedule.scheduleBookings,
maxWagons: schedule.maxWagons,
});
return {
@@ -6847,6 +6850,7 @@ export class TrainSchedulingService {
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -6903,6 +6907,7 @@ export class TrainSchedulingService {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -6948,9 +6953,7 @@ export class TrainSchedulingService {
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
train_number: string | null;
}
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.reference AS reference,
@@ -6980,14 +6983,13 @@ export class TrainSchedulingService {
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({
return rows.map((r) =>
this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
trainNumber: r.train_number,
}));
);
}
private mapBookingWindowRow(r: BookingWindowRow) {
@@ -7005,6 +7007,7 @@ export class TrainSchedulingService {
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
trainNumber: r.train_number ?? null,
contractId: r.contract_id,
contractKind: r.contract_kind,
direction: r.direction,
@@ -9301,9 +9304,14 @@ export class TrainSchedulingService {
}
// Every schedule the target train is committed to, via its train sets.
// Locomotives come along: the merged train is pulled by the union of this
// schedule's locos and the target train's, so capacity checks need both.
const targetSets = await this.dataSource
.getRepository(TrainSet)
.find({ where: { trainId: targetTrainId } });
.find({
where: { trainId: targetTrainId },
relations: { locomotives: { locomotive: true }, locomotive: true },
});
const targetSetIds = targetSets.map((s) => s.id);
const targetSchedules = targetSetIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
@@ -9346,6 +9354,24 @@ export class TrainSchedulingService {
.getRepository(Wagon)
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
// EVERY wagon physically on the source train moves with the merge — not
// just the ones coupled into this schedule's set. A wagon left behind
// would strand on the deactivated train. "Loose" = on the train but not
// backing a set slot; it joins the counts and the capacity math.
const sourceWagons = sourceTrainId
? await this.dataSource
.getRepository(Wagon)
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
: [];
const coupledPhysicalIds = new Set(
(schedule.trainSet?.wagons ?? [])
.map((w) => w.physicalWagonId)
.filter(Boolean),
);
const looseSourceWagons = sourceWagons.filter(
(w) => !coupledPhysicalIds.has(w.id),
);
const movingBookings = absorbed
? await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: absorbed.id },
@@ -9357,10 +9383,12 @@ export class TrainSchedulingService {
schedule,
sourceTrainId,
targetTrain,
targetSets,
absorbed,
affectedOthers,
untouched,
incomingWagons,
looseSourceWagons,
movingBookings,
};
}
@@ -9382,14 +9410,20 @@ export class TrainSchedulingService {
);
}
// ── Capacity: the merged consist must fit this schedule's locomotives ────
// ── Capacity: the merged consist must fit the merged train's locomotives ─
// Existing side = coupled set slots PLUS loose wagons riding the source
// train without a slot — they all move, so they all count.
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters) || 0,
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
cargoTons: 0,
}));
const wagonTypeIds = [
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
...new Set(
[...incomingWagons, ...plan.looseSourceWagons]
.map((w) => w.wagonTypeId)
.filter(Boolean),
),
];
const wagonTypes = wagonTypeIds.length
? await this.dataSource
@@ -9397,16 +9431,27 @@ export class TrainSchedulingService {
.find({ where: { id: In(wagonTypeIds) } })
: [];
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
const incomingSlots = incomingWagons.map((w) => {
const slotFromWagon = (w: Wagon) => {
const t = typeById.get(w.wagonTypeId);
return {
lengthMeters: Number(t?.lengthMeters) || 0,
tareWeightTons: Number(t?.tareWeightTons) || 0,
cargoTons: 0,
};
});
};
const incomingSlots = incomingWagons.map(slotFromWagon);
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
const limits = trainSetLocomotiveLimits(schedule.trainSet);
// The merged train is pulled by the union of this schedule's locomotives
// and whatever already pulls the target train (its sets keep their locos).
// Pull weight adds up across the pool; length stays the tightest cap.
const locoPool = [
...this.locomotivesOfTrainSet(schedule.trainSet),
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
];
const limits = combinedLocomotiveLimits([
...new Map(locoPool.map((l) => [l.id, l])).values(),
]);
if (limits) {
const rules = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
@@ -9415,13 +9460,17 @@ export class TrainSchedulingService {
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
});
const merged = [...existingSlots, ...incomingSlots];
// maxWagons is the schedule's own slot ceiling; fall back to the consist
// size when it is unset so the count axis never blocks spuriously.
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
// Merge is a physical consist move, so only the physical axes gate it:
// can this schedule's locomotives pull the merged weight and length.
// `schedule.maxWagons` is the booking-window planning ceiling — using it
// as a slot cap here blocked every merge into a bigger train (e.g. a
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
// ceiling to the merged size instead.
const violations = consistViolations(merged, {
maxWeightTons: caps.maxWeightTons,
maxLengthMeters: caps.maxLengthMeters,
maxWagonSlots: schedule.maxWagons || merged.length,
maxWagonSlots: merged.length,
});
blockers.push(...violations);
}
@@ -9481,7 +9530,10 @@ export class TrainSchedulingService {
const plan = await this.planMerge(scheduleId, targetTrainId);
const blockers = await this.mergeBlockers(plan);
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
// Coupled slots plus loose wagons on the source train — everything moves.
const existingCount =
(plan.schedule.trainSet?.wagons?.length ?? 0) +
plan.looseSourceWagons.length;
return {
canMerge: blockers.length === 0,
blockers,
@@ -9555,13 +9607,20 @@ export class TrainSchedulingService {
trainId: targetTrain.id,
});
// 2. The physical wagons follow the train.
// 2. The physical wagons follow the train — the target's stay put, and
// EVERY wagon on the source train (coupled or loose) moves across so
// nothing strands on the deactivated train.
if (incomingWagons.length) {
await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) },
{ trainId: targetTrain.id },
);
}
if (sourceTrainId) {
await manager
.getRepository(Wagon)
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
}
// 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder
@@ -9611,6 +9670,14 @@ export class TrainSchedulingService {
await manager
.getRepository(TrainSet)
.update(trainSetId, { wagonCount: mergedCount });
// 8. Booking capacity follows the consist: raise (never lower) the
// planning ceiling so the merged wagons are actually sellable.
if (mergedCount > (schedule.maxWagons ?? 0)) {
await manager
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: mergedCount });
}
});
this.logger.log(

View File

@@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => {
expect(usage.wagonsRemaining).toBe(0);
});
it('sells against the planned ceiling, not the partially coupled consist', () => {
// S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking
// that reserved 2 — the list read "2/2 used, 0 bookable" while the detail
// page and the booking gate (remainingWagonsForLeg vs maxWagons) both said
// 1 wagon was still free. Wagons couple on demand; the ceiling is capacity.
const usage = computeScheduleWagonUsage({
wagonSlots: Array(2).fill(slot(true)),
storedWagonCount: 2,
scheduleBookings: [booking(2)],
maxWagons: 3,
});
expect(usage.wagonsUsed).toBe(2);
expect(usage.wagonsTotal).toBe(3);
expect(usage.wagonsRemaining).toBe(1);
});
it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: [],

View File

@@ -20,11 +20,11 @@ export interface ScheduleBookingLike {
export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */
/** Schedule capacity — the larger of coupled consist and planned ceiling. */
wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */
/** Capacity minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number;
}
@@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: {
/** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null;
/** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */
maxWagons?: number | null;
}): ScheduleWagonUsage {
const slots = input.wagonSlots ?? [];
@@ -42,7 +44,14 @@ export function computeScheduleWagonUsage(input: {
// Prefer live slot rows; the stored counter drifts when a consist is edited
// without a recompute, which is why the list and detail disagreed on totals.
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
const coupled = slots.length || (input.storedWagonCount ?? 0);
// Wagons are coupled on demand as bookings are allocated, so a partially
// built consist does not cap what is bookable — the planned ceiling does
// (remainingWagonsForLeg sells against maxWagons). Without this, a schedule
// planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable"
// while its detail page and the booking gate both said 1 wagon was free.
const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0);
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce(

View File

@@ -1019,14 +1019,11 @@ export default function TrainScheduleV2ListPage() {
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation (never the coupled consist size), and remaining
* excludes wagons reserved by bookings that have not paid yet — that space is
* claimed, so it is not bookable.
*
* Schedules whose train set has not been built yet have no consist to measure,
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
* though every one of its wagons is still free.
* carrying a booking allocation, the denominator is the schedule's capacity
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
* wagons are coupled on demand), and remaining excludes wagons reserved by
* bookings that have not paid yet — that space is claimed, so it is not
* bookable.
*/
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
@@ -1034,17 +1031,7 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
// Until the train set is built there is no consist to measure against, so
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
// which reads as "fully booked" when in fact nothing is booked at all. Before
// a consist exists, capacity is the planned ceiling minus what bookings have
// already claimed.
const planCeiling = schedule.maxWagons ?? 0;
const remaining =
total === 0 && planCeiling > 0
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
: schedule.wagonsRemaining;
const remaining = schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
@@ -1052,11 +1039,9 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
return (
<>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />