mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 21:13:37 +00:00
- 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>
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
import type { CorridorLeg } from './corridor-capacity.util';
|
|
|
|
/**
|
|
* Physical wagon-type stock for one train, consumed per corridor edge.
|
|
*
|
|
* The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight,
|
|
* length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons"
|
|
* would admit a 20-wagon booking whose cargo only rides NW5 even when the yard
|
|
* holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20,
|
|
* and allocation then failed on wagon 17 with "No NW5 wagon available at the
|
|
* yard" — money taken for space that never existed.
|
|
*
|
|
* This ledger is the missing axis: how many wagons of the types a booking may
|
|
* actually ride are free. Batch fill consults it alongside the budget, so a
|
|
* booking is admitted whole only when both agree, and is otherwise offered a
|
|
* split sized to the wagons that genuinely exist.
|
|
*
|
|
* Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at
|
|
* an alight yard is available again downstream, so an intercity ride-along on
|
|
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
|
|
*/
|
|
export class WagonStockLedger {
|
|
/**
|
|
* Usage rows keyed by pool. A single-yard train has one pool (''), so this is
|
|
* exactly the original per-type accounting. A multi-yard consist keys by
|
|
* boarding yard as well, because the Dire wagons and the Mojo wagons are
|
|
* disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the
|
|
* Mojo→Addis edge, but they must not shrink what Mojo itself can offer.
|
|
*/
|
|
private readonly usedPerEdge = new Map<string, number[]>();
|
|
|
|
constructor(
|
|
private readonly remainingByTypeId: Map<string, number>,
|
|
private readonly edgeCount: number,
|
|
/**
|
|
* Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons
|
|
* standing at each yard. When present, a leg is served ONLY by the wagons
|
|
* standing at the yard it boards from — a Dire→Addis booking on a train
|
|
* whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis
|
|
* booking sees 33, never the Dire wagons that ride past empty.
|
|
*/
|
|
private readonly byYardId?: Map<string, Map<string, number>>,
|
|
/** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */
|
|
private readonly stops: readonly string[] = [],
|
|
) {}
|
|
|
|
/** The yard a leg boards from, or '' when the train is not split across yards. */
|
|
private poolYardOf(leg: CorridorLeg): string {
|
|
if (!this.byYardId) return '';
|
|
return this.stops[leg.fromEdge] ?? '';
|
|
}
|
|
|
|
/** Usage-row key: one row per (pool, wagon type). */
|
|
private rowKey(wagonTypeId: string, leg: CorridorLeg): string {
|
|
const pool = this.poolYardOf(leg);
|
|
return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
|
|
}
|
|
|
|
/** Wagons of one type offered at the yard a leg boards from. */
|
|
private totalForType(wagonTypeId: string, leg: CorridorLeg): number {
|
|
const pool = this.poolYardOf(leg);
|
|
if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0;
|
|
return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0;
|
|
}
|
|
|
|
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
|
|
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
|
|
const total = this.totalForType(wagonTypeId, leg);
|
|
const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg));
|
|
if (!row) return total;
|
|
let busiest = 0;
|
|
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
|
busiest = Math.max(busiest, row[edge] ?? 0);
|
|
}
|
|
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.
|
|
*/
|
|
availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number {
|
|
let total = 0;
|
|
for (const id of new Set(wagonTypeIds)) {
|
|
total += this.availableForType(id, leg);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Take `wagons` from the candidate types, deepest stock first so the consist
|
|
* drains evenly (same tie-break as the wagon planner). Returns how many were
|
|
* actually taken — less than asked when the stock is short.
|
|
*/
|
|
consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number {
|
|
let outstanding = Math.max(0, Math.floor(wagons));
|
|
const candidates = [...new Set(wagonTypeIds)];
|
|
let taken = 0;
|
|
|
|
while (outstanding > 0) {
|
|
const deepest = candidates
|
|
.map((id) => ({ id, free: this.availableForType(id, leg) }))
|
|
.filter((c) => c.free > 0)
|
|
.sort((a, b) => b.free - a.free)[0];
|
|
if (!deepest) break;
|
|
|
|
const take = Math.min(outstanding, deepest.free);
|
|
const key = this.rowKey(deepest.id, leg);
|
|
let row = this.usedPerEdge.get(key);
|
|
if (!row) {
|
|
row = new Array<number>(this.edgeCount).fill(0);
|
|
this.usedPerEdge.set(key, row);
|
|
}
|
|
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
|
|
row[edge] = (row[edge] ?? 0) + take;
|
|
}
|
|
outstanding -= take;
|
|
taken += take;
|
|
}
|
|
|
|
return taken;
|
|
}
|
|
}
|