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

@@ -54,6 +54,13 @@ export type WagonStock = {
* math.
*/
byYardId?: Map<string, Map<string, number>>;
/**
* Wagons the schedule CUTS mid-route (staff plan): each is stock only up to
* its cut stop. Consumers debit it from its pool on every edge at/after the
* cut, so a leg riding past the cut never counts it. Absent = no cuts.
* `poolYardId` is the wagon's boarding pool ('' on a single-yard consist).
*/
cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>;
};
export type FlexPlanResult = {
@@ -324,6 +331,16 @@ export function planWagonsWithStock(params: {
}
return row;
};
// Cut wagons are pre-consumed on every edge at/after their cut stop: they
// are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops
// given / off-corridor) is skipped — conservative, same as before cuts.
for (const cut of stock.cutWagons ?? []) {
const fromEdge = stops.indexOf(cut.cutYardId);
if (fromEdge < 0) continue;
const pool = stock.byYardId ? cut.poolYardId : '';
const row = usedRow(rowKeyFor(cut.wagonTypeId, pool));
for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1;
}
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool);