From f882104ed37691f840a195056380ee1772d5411a Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 11:23:10 +0000 Subject: [PATCH] fix(train-scheduling): move loose wagons and locomotives on train merge --- .../services/train-scheduling.service.ts | 105 ++++++++++++++---- .../utils/schedule-wagon-usage.util.spec.ts | 17 +++ .../utils/schedule-wagon-usage.util.ts | 15 ++- .../TrainScheduleV2ListPage.tsx | 31 ++---- 4 files changed, 123 insertions(+), 45 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index a740dc932..cdc43c5b2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -330,6 +330,8 @@ const DEFAULT_TRAIN_LIMITS: Required = { 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 = 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 & { - train_number: string | null; - } + Omit > = 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( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts index 8fa9c1260..6507b8a8c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts @@ -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: [], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts index f48d60f9a..899e4c066 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts @@ -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( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 9755e6bee..51899fc51 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -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 ; @@ -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. */} 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 ? (