mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(train-scheduling): move loose wagons and locomotives on train merge
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user