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

@@ -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(