feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- planned couples: loose wagons join the train at a route stop, added
  from the schedule yards tab; capacity credits them per corridor edge
  and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
  its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
  every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
  endpoints; builder detaches now always write adjustment-log rows

Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
34 changed files with 2532 additions and 202 deletions

View File

@@ -75,6 +75,32 @@ export class WagonStockLedger {
return Math.max(0, total - busiest);
}
/**
* Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its
* pool's stock on every edge at/after its cut stop, so a leg riding past the
* cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu).
* A cut yard not on this ledger's stops is skipped — conservative, matches
* the pre-cut behavior.
*/
debitCutWagons(
cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>,
): void {
for (const cut of cuts) {
const fromEdge = this.stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = this.byYardId ? cut.poolYardId : '';
const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId;
let row = this.usedPerEdge.get(key);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(key, row);
}
for (let edge = fromEdge; edge < this.edgeCount; edge += 1) {
row[edge] = (row[edge] ?? 0) + 1;
}
}
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.