feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -93,6 +93,12 @@ type OpenSlot = {
legKey: string;
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
covered: { from: number; to: number };
/**
* Boarding-yard pool this wagon was opened from — the yard the consist plans
* it at (`''` when the consist is not split across yards). A Mojo wagon
* cannot later be stretched back to board at Gelan.
*/
pool: string;
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
@@ -197,9 +203,19 @@ export function planWagonsWithStock(params: {
*/
legs?: Map<string, BookingLeg>;
edgeCount?: number;
/**
* Ordered corridor stop ids, parallel to the edges. Required for a consist
* split across yards (`stock.byYardId`): a booking then draws ONLY from the
* wagons planned at the yard it boards from (`stops[leg.from]`) — the
* whole-train count would happily plan 17 Mojo wagons on a train that has
* 15 there and 31 in Gelan, and the physical pin then fails after the
* customer has paid.
*/
stops?: readonly string[];
}): FlexPlanResult {
const { bookings, allowed, stock, legs } = params;
const edgeCount = Math.max(1, params.edgeCount ?? 1);
const stops = params.stops ?? [];
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
@@ -214,32 +230,45 @@ export function planWagonsWithStock(params: {
};
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
// Wagons of a type in use per corridor edge. A type is available for a leg
// when its busiest edge WITHIN that leg still has stock spare — the max over
// edges is the number of physical wagons the type needs simultaneously.
// Split consist: each boarding yard is its own pool of steel (mirrors
// WagonStockLedger). Single-yard consist / loose yard pool: one pool ''.
const poolOf = (leg: BookingLeg): string =>
stock.byYardId ? (stops[leg.from] ?? '') : '';
const rowKeyFor = (wagonTypeId: string, pool: string): string =>
pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
const totalFor = (wagonTypeId: string, pool: string): number =>
pool
? (stock.byYardId?.get(pool)?.get(wagonTypeId) ?? 0)
: (stock.remainingByTypeId.get(wagonTypeId) ?? 0);
// Wagons of a type in use per corridor edge, per pool. A type is available
// for a leg when its busiest edge WITHIN that leg still has stock spare — the
// max over edges is the number of physical wagons the type needs simultaneously.
const usedPerEdge = new Map<string, number[]>();
const usedRow = (wagonTypeId: string): number[] => {
let row = usedPerEdge.get(wagonTypeId);
const usedRow = (key: string): number[] => {
let row = usedPerEdge.get(key);
if (!row) {
row = new Array<number>(edgeCount).fill(0);
usedPerEdge.set(wagonTypeId, row);
usedPerEdge.set(key, row);
}
return row;
};
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = usedPerEdge.get(wagonTypeId);
const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool);
const row = usedPerEdge.get(rowKeyFor(wagonTypeId, pool));
if (!row) return total;
let busiest = 0;
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
return total - busiest;
};
const noStockMessage = (candidates: WagonType[]): string => {
const noStockMessage = (candidates: WagonType[], leg: BookingLeg): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
if (stock.mode !== 'TRAIN') return `No available ${codes} wagon at the yard`;
return poolOf(leg)
? `Train has no free ${codes} wagon planned at the boarding yard`
: `Train has no free ${codes} wagon left`;
};
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
@@ -251,7 +280,7 @@ export function planWagonsWithStock(params: {
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
}
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
@@ -261,7 +290,8 @@ export function planWagonsWithStock(params: {
availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg),
)[0];
const row = usedRow(chosen.id);
const pool = poolOf(leg);
const row = usedRow(rowKeyFor(chosen.id, pool));
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
@@ -271,6 +301,7 @@ export function planWagonsWithStock(params: {
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
pool,
};
openSlots.push(open);
return open;
@@ -290,8 +321,11 @@ export function planWagonsWithStock(params: {
* slot's type spare — extending the span puts this wagon on those edges.
*/
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
// A pooled wagon boards where its yard is; it cannot be stretched back to
// an EARLIER stop (the steel is not there), only ridden further.
if (open.pool && leg.from < open.covered.from) return false;
const total = totalFor(open.slot.wagonTypeId, open.pool);
const row = usedPerEdge.get(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
@@ -303,7 +337,7 @@ export function planWagonsWithStock(params: {
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
const row = usedRow(open.slot.wagonTypeId);
const row = usedRow(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {