mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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>
775 lines
32 KiB
TypeScript
775 lines
32 KiB
TypeScript
import { AllocationLoadType } from '@edr/types';
|
||
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||
import {
|
||
bookingCargoTons,
|
||
bulkItemsFitFor,
|
||
bulkTonsPerWagon,
|
||
bulkWagonsForAllowedTypes,
|
||
} from './train-capacity.util';
|
||
import {
|
||
sortBookingsForScheduling,
|
||
type BookingWagonShortage,
|
||
type DeferredBookingRow,
|
||
} from './utils/fleet-plan.util';
|
||
import {
|
||
MAX_TEU_SLOTS_PER_WAGON,
|
||
containerWagonsForLines,
|
||
expandBookingContainerUnits,
|
||
roundTons,
|
||
tareTonsOf,
|
||
teuSlotsForSizeFt,
|
||
type SlotLoadType,
|
||
type WagonPlanSlot,
|
||
} from './utils/wagon-plan.util';
|
||
|
||
/**
|
||
* Wagon types allowed to carry each container type / bulk cargo type — the
|
||
* many-to-many configuration lists, resolved once per validation run.
|
||
*/
|
||
export type AllowedWagonTypeMap = {
|
||
byContainerTypeId: Map<string, WagonType[]>;
|
||
byCargoTypeId: Map<string, WagonType[]>;
|
||
};
|
||
|
||
/**
|
||
* Plannable wagon inventory. TRAIN mode is the built train's own consist —
|
||
* a hard cap, the plan never reaches for loose yard wagons. YARD mode is the
|
||
* AVAILABLE pool at the boarding yards (legacy schedules).
|
||
*/
|
||
export type WagonStock = {
|
||
mode: 'TRAIN' | 'YARD';
|
||
/** Remaining plannable wagons per wagon type id. Missing type = 0. */
|
||
remainingByTypeId: Map<string, number>;
|
||
/** Wagon-type code per id, for human-readable shortfall messages. */
|
||
codesByTypeId: Map<string, string>;
|
||
/**
|
||
* Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons
|
||
* standing at that yard. A train whose wagons are split across yards can
|
||
* only offer, at each boarding yard, the wagons physically standing there —
|
||
* a wagon waiting in Mojo is not bookable from Dire, and one picked up at
|
||
* Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits
|
||
* in one yard, which keeps single-yard trains on the original whole-train
|
||
* 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 = {
|
||
plan: WagonPlanSlot[];
|
||
fitting: Booking[];
|
||
deferred: DeferredBookingRow[];
|
||
/**
|
||
* Misconfiguration (a scheduled type with no wagon types configured) —
|
||
* a hard violation, unlike stock shortfalls which merely defer bookings.
|
||
*/
|
||
configIssues: string[];
|
||
};
|
||
|
||
type OpenSlot = {
|
||
slot: WagonPlanSlot;
|
||
/**
|
||
* TEU occupied PER CORRIDOR EDGE. Containers on different legs share the
|
||
* same physical wagon as long as no single edge exceeds the wagon's TEU
|
||
* geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft
|
||
* boarding there, and two overlapping-leg 20fts coexist while both ride.
|
||
*/
|
||
teuPerEdge: number[];
|
||
kind: SlotLoadType;
|
||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||
cargoTypeId: string | null;
|
||
freeCapacityTons: number;
|
||
/**
|
||
* Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only —
|
||
* bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined
|
||
* for weight-only (PER_TON) bulk and container wagons.
|
||
*/
|
||
freeItems?: number;
|
||
/**
|
||
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
||
* prefer a same-leg slot but may extend onto a different-leg one (span
|
||
* grows to the union); bulk still shares only on an identical leg.
|
||
*/
|
||
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. */
|
||
export type BookingLeg = { from: number; to: number };
|
||
|
||
type PlacementProblem = {
|
||
kind: 'config' | 'stock';
|
||
message: string;
|
||
/** Wagon types the failing placement could have used (stock problems only). */
|
||
candidates?: WagonType[];
|
||
};
|
||
|
||
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
|
||
sequenceNo: 0, // stamped at the end
|
||
wagonTypeId: wagonType.id,
|
||
wagonTypeCode: wagonType.code,
|
||
capacityTons: Number(wagonType.capacityTons),
|
||
lengthMeters: Number(wagonType.lengthMeters),
|
||
tareWeightTons: tareTonsOf(wagonType),
|
||
assignedWeightTons: 0,
|
||
allocations: [],
|
||
slotLoadType: kind,
|
||
});
|
||
|
||
/**
|
||
* Booking-level shortage against the wagon types the failing placement could
|
||
* use: wagons the whole booking needs vs stock left for those types. Container
|
||
* counts are TEU-packed per booking; bulk divides by the largest candidate.
|
||
*/
|
||
const shortageFor = (
|
||
booking: Booking,
|
||
candidates: WagonType[],
|
||
availableOf: (wagonTypeId: string) => number,
|
||
): BookingWagonShortage => {
|
||
const wagonsNeeded =
|
||
booking.freightType === 'BULK'
|
||
? Math.max(
|
||
1,
|
||
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||
// respected); PER_TON divides by its per-wagon tonnage cap where one
|
||
// is configured, else the largest candidate's rating.
|
||
// bookingCargoTons, not raw VGM — for PER_ITEM that column is the
|
||
// item count, not tons.
|
||
bulkWagonsForAllowedTypes(
|
||
booking,
|
||
booking.cargoType,
|
||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||
) ||
|
||
Math.ceil(
|
||
bookingCargoTons(booking) /
|
||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||
),
|
||
)
|
||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||
|
||
const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) }));
|
||
const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0);
|
||
|
||
// PER_TON bulk: a bare wagon COUNT lies when the types carry different
|
||
// tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free"
|
||
// against a 24-wagon need, yet only 620T of the 695T booking fits — which
|
||
// is how a deferral could read "needs 24, 24 available (short 1)". Size the
|
||
// shortfall in the wagons the cargo's OWN caps require: how many more
|
||
// wagons of the best remaining type would carry the leftover tonnage.
|
||
const tons = bookingCargoTons(booking);
|
||
const perItem =
|
||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||
if (booking.freightType === 'BULK' && !perItem && tons > 0) {
|
||
let seatable = 0;
|
||
let usedWagons = 0;
|
||
for (const { wt, free } of freeByType) {
|
||
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
|
||
if (!(perWagon > 0) || free <= 0) continue;
|
||
seatable += free * perWagon;
|
||
usedWagons += free;
|
||
}
|
||
if (seatable < tons) {
|
||
const bestPerWagon = Math.max(
|
||
1,
|
||
...candidates.map((wt) =>
|
||
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
|
||
),
|
||
);
|
||
return {
|
||
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
|
||
wagonsNeeded,
|
||
wagonsAvailable: usedWagons,
|
||
// Wagons of the best type still missing to carry the leftover tonnage.
|
||
wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)),
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
|
||
wagonsNeeded,
|
||
wagonsAvailable,
|
||
wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable),
|
||
};
|
||
};
|
||
|
||
const addAllocation = (
|
||
slot: WagonPlanSlot,
|
||
bookingId: string,
|
||
bookingReference: string,
|
||
weightTons: number,
|
||
loadType: AllocationLoadType,
|
||
) => {
|
||
let allocation = slot.allocations.find((a) => a.bookingId === bookingId);
|
||
if (!allocation) {
|
||
allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType };
|
||
slot.allocations.push(allocation);
|
||
}
|
||
allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons);
|
||
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons);
|
||
};
|
||
|
||
/**
|
||
* Build the wagon plan against a wagon-type inventory, mixing wagon types
|
||
* within one consist. Each booking is atomic: it either fits entirely (its
|
||
* containers/tonnage placed on wagons whose type is allowed for its container
|
||
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
|
||
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
|
||
* two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only,
|
||
* filled to the cargo type's per-wagon cap. Type choice is scarcity-aware:
|
||
* least-shareable wagon type first, so bulk with a PW2 alternative leaves the
|
||
* container-capable NW5s to the containers.
|
||
*/
|
||
export function planWagonsWithStock(params: {
|
||
bookings: Booking[];
|
||
allowed: AllowedWagonTypeMap;
|
||
stock: WagonStock;
|
||
/**
|
||
* Leg-aware stock: booking id → the stop-index range it rides. When given
|
||
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
|
||
* the same physical wagon can serve an intercity booking on Gelan→Adama and
|
||
* an export booking on Adama→Doraleh — disjoint legs never compete for
|
||
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
|
||
*/
|
||
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[] = [];
|
||
const configIssues = new Set<string>();
|
||
|
||
// Scarcity rank: how many distinct demand groups (container types / bulk
|
||
// cargo types) among THESE bookings can ride each wagon type. When a cargo
|
||
// can choose, it takes the least-shareable type first, keeping versatile
|
||
// types (e.g. container-capable NW5) free for the cargo that has no
|
||
// alternative. A type nobody else wants ranks 1; unranked types rank 1 too
|
||
// (nothing competes for them).
|
||
const demandGroups = new Map<string, WagonType[]>();
|
||
for (const b of bookings) {
|
||
if (b.freightType === 'CONTAINER') {
|
||
for (const line of b.bookingContainers ?? []) {
|
||
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||
if (!containerTypeId) continue;
|
||
demandGroups.set(
|
||
`C:${containerTypeId}`,
|
||
allowed.byContainerTypeId.get(containerTypeId) ?? [],
|
||
);
|
||
}
|
||
} else {
|
||
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
|
||
if (cargoTypeId) {
|
||
demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
|
||
}
|
||
}
|
||
}
|
||
const scarcityRank = new Map<string, number>();
|
||
for (const types of demandGroups.values()) {
|
||
for (const wt of types) {
|
||
scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1);
|
||
}
|
||
}
|
||
const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1;
|
||
|
||
const legFor = (booking: Booking): BookingLeg => {
|
||
const leg = legs?.get(booking.id);
|
||
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||
return { from: 0, to: edgeCount };
|
||
}
|
||
return leg;
|
||
};
|
||
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
||
|
||
// 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 = (key: string): number[] => {
|
||
let row = usedPerEdge.get(key);
|
||
if (!row) {
|
||
row = new Array<number>(edgeCount).fill(0);
|
||
usedPerEdge.set(key, row);
|
||
}
|
||
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);
|
||
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[], leg: BookingLeg): string => {
|
||
const codes = candidates.map((wt) => wt.code).join('/');
|
||
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. */
|
||
const openSlot = (
|
||
candidates: WagonType[],
|
||
kind: SlotLoadType,
|
||
cargoTypeId: string | null,
|
||
leg: BookingLeg,
|
||
/** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */
|
||
cargoType?: Booking['cargoType'],
|
||
): OpenSlot | PlacementProblem => {
|
||
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||
if (!inStock.length) {
|
||
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
|
||
}
|
||
// Least-shareable type first (see scarcityRank) so cargo with alternatives
|
||
// never starves cargo without one. Bulk then favors the biggest per-wagon
|
||
// take for THIS cargo (its configured cap, not the raw rating); containers
|
||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||
const bulkTakeOf = (wt: WagonType): number =>
|
||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons));
|
||
const chosen = [...inStock].sort((a, b) =>
|
||
kind === 'BULK'
|
||
? rankOf(a) - rankOf(b) ||
|
||
bulkTakeOf(b) - bulkTakeOf(a) ||
|
||
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||
: rankOf(a) - rankOf(b) ||
|
||
availableFor(b.id, leg) - availableFor(a.id, leg),
|
||
)[0];
|
||
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),
|
||
teuPerEdge: new Array<number>(edgeCount).fill(0),
|
||
kind,
|
||
cargoTypeId,
|
||
// A bulk wagon fills to the cargo type's configured per-wagon cap
|
||
// (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating.
|
||
freeCapacityTons:
|
||
kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons),
|
||
legKey: legKeyOf(leg),
|
||
covered: { ...leg },
|
||
pool,
|
||
};
|
||
openSlots.push(open);
|
||
return open;
|
||
};
|
||
|
||
/** TEU room on every edge of the unit's leg. */
|
||
const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => {
|
||
for (let e = leg.from; e < leg.to; e += 1) {
|
||
if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/**
|
||
* Whether the slot's ridden span can grow to include this leg: every NEW
|
||
* edge (outside the current span) must still have a physical wagon of the
|
||
* slot's type spare — extending the span puts this wagon on those edges.
|
||
*/
|
||
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
|
||
// 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) {
|
||
if (e >= open.covered.from && e < open.covered.to) continue;
|
||
if (total - (row?.[e] ?? 0) <= 0) return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
|
||
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
|
||
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) {
|
||
if (e >= open.covered.from && e < open.covered.to) continue;
|
||
row[e] = (row[e] ?? 0) + 1;
|
||
}
|
||
open.covered = { from, to };
|
||
};
|
||
|
||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||
const leg = legFor(booking);
|
||
const legKey = legKeyOf(leg);
|
||
if (booking.freightType === 'CONTAINER') {
|
||
const units = expandBookingContainerUnits([booking]);
|
||
if (!units.length) {
|
||
// Degenerate container booking with no lines still reserves one wagon
|
||
// (legacy behavior) — but there is no container type to resolve against.
|
||
return {
|
||
kind: 'config',
|
||
message: `Booking ${booking.reference} has no container lines to plan`,
|
||
};
|
||
}
|
||
for (const unit of units) {
|
||
const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? [];
|
||
if (!candidates.length) {
|
||
return {
|
||
kind: 'config',
|
||
message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||
};
|
||
}
|
||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||
// A BULK wagon whose cargo alights before this unit boards is empty
|
||
// steel again and may carry containers on the later leg (and vice
|
||
// versa — see the bulk reuse pass). While both ride together, the
|
||
// kinds never mix.
|
||
const disjointFrom = (open: OpenSlot): boolean =>
|
||
open.covered.to <= leg.from || leg.to <= open.covered.from;
|
||
const fitsSlot = (open: OpenSlot): boolean =>
|
||
(open.kind === 'CONTAINER' || disjointFrom(open)) &&
|
||
allowedIds.has(open.slot.wagonTypeId) &&
|
||
teuFits(open, leg, teu) &&
|
||
canExtendSpan(open, leg);
|
||
// Same-leg slots first (keeps legacy packing byte-identical), then any
|
||
// open wagon with per-edge TEU room — an intercity 20ft rides an
|
||
// export wagon's spare slot instead of appending a new wagon.
|
||
let target =
|
||
openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ??
|
||
openSlots.find(fitsSlot);
|
||
if (!target) {
|
||
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||
if ('message' in openedSlot) return openedSlot;
|
||
target = openedSlot;
|
||
} else {
|
||
extendSpan(target, leg);
|
||
}
|
||
addAllocation(
|
||
target.slot,
|
||
unit.bookingId,
|
||
unit.bookingReference,
|
||
unit.grossWeightTons,
|
||
AllocationLoadType.Container,
|
||
);
|
||
for (let e = leg.from; e < leg.to; e += 1) {
|
||
target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// BULK — weight-based, one cargo type per wagon.
|
||
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null;
|
||
const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
|
||
if (!candidates.length) {
|
||
return {
|
||
kind: 'config',
|
||
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||
};
|
||
}
|
||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
|
||
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
|
||
// it either way. Items are indivisible, so a wagon takes whole items only,
|
||
// bounded by tonnage AND by the cargo type's items-per-wagon fit.
|
||
const quantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||
const perItem =
|
||
Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0;
|
||
let remainingWeight = roundTons(bookingCargoTons(booking));
|
||
const perItemTons = perItem ? remainingWeight / quantity : 0;
|
||
let remainingItems = perItem ? quantity : 0;
|
||
|
||
/** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */
|
||
const itemBudgetOf = (open: OpenSlot): number => {
|
||
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
|
||
const byTonnage =
|
||
perItemTons > 0
|
||
? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons))
|
||
: 1;
|
||
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
|
||
};
|
||
let placedAnywhere = false;
|
||
|
||
// Per-item: least-shareable type first (same scarcity rule as openSlot),
|
||
// then the type carrying the most whole items per wagon.
|
||
const itemBudgetOfType = (wt: WagonType): number =>
|
||
Math.min(
|
||
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
|
||
perItemTons > 0
|
||
? Math.max(
|
||
1,
|
||
Math.floor(
|
||
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) /
|
||
perItemTons,
|
||
),
|
||
)
|
||
: 1,
|
||
);
|
||
const orderedCandidates = perItem
|
||
? [...candidates].sort(
|
||
(a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a),
|
||
)
|
||
: candidates;
|
||
|
||
// One bulk booking per wagon PER LEG: a wagon carrying bulk takes that one
|
||
// booking's cargo for as long as it rides — never topped up from another
|
||
// booking on the same edges, even of the same cargo type.
|
||
//
|
||
// A wagon whose cargo ALIGHTS before this booking boards is free steel
|
||
// again, though: an import container uncoupled at Dire Dawa leaves its
|
||
// wagon empty for bulk loading there. Reuse those disjoint-leg slots
|
||
// before opening new stock — containers already do this, and without it a
|
||
// train with 3 wagons could not seat 3 wagons of leg-1 cargo plus 3 of
|
||
// leg-2 cargo.
|
||
const disjoint = (open: OpenSlot): boolean =>
|
||
open.covered.to <= leg.from || leg.to <= open.covered.from;
|
||
const reusable = openSlots.filter(
|
||
(open) =>
|
||
disjoint(open) &&
|
||
allowedIds.has(open.slot.wagonTypeId) &&
|
||
// A pooled wagon boards at its own yard; it cannot ride backwards.
|
||
!(open.pool && leg.from < open.covered.from),
|
||
);
|
||
for (const open of reusable) {
|
||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||
const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
|
||
if (!wagonType) continue;
|
||
const room = bulkTonsPerWagon(
|
||
booking.cargoType,
|
||
open.slot.wagonTypeId,
|
||
Number(open.slot.capacityTons),
|
||
);
|
||
if (!(room > 0)) continue;
|
||
let take: number;
|
||
if (perItem) {
|
||
const budget = Math.min(
|
||
bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId) ??
|
||
Number.MAX_SAFE_INTEGER,
|
||
perItemTons > 0 ? Math.max(1, Math.floor(room / perItemTons)) : 1,
|
||
);
|
||
const takeItems = Math.max(1, Math.min(budget, remainingItems));
|
||
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
|
||
remainingItems -= takeItems;
|
||
} else {
|
||
take = roundTons(Math.min(room, remainingWeight));
|
||
}
|
||
addAllocation(
|
||
open.slot,
|
||
booking.id,
|
||
booking.reference,
|
||
take,
|
||
AllocationLoadType.Bulk,
|
||
);
|
||
// The wagon now rides this leg too — it is the same physical steel, so
|
||
// no extra stock is consumed beyond extending its span.
|
||
extendSpan(open, leg);
|
||
remainingWeight = roundTons(remainingWeight - take);
|
||
placedAnywhere = true;
|
||
}
|
||
|
||
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
|
||
// Per-item: openSlot's stock-depth tie-break would override the fit
|
||
// preference, so hand it exactly the best in-stock type (full candidate
|
||
// list only when none has stock, for the proper shortfall message).
|
||
const inStockBest = perItem
|
||
? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0)
|
||
: undefined;
|
||
const openedSlot = openSlot(
|
||
inStockBest ? [inStockBest] : orderedCandidates,
|
||
'BULK',
|
||
cargoTypeId,
|
||
leg,
|
||
booking.cargoType,
|
||
);
|
||
if ('message' in openedSlot) return openedSlot;
|
||
let take: number;
|
||
if (perItem) {
|
||
// An item heavier than a whole wagon still charges 1 wagon per item
|
||
// (creation-time validation owns rejecting that case).
|
||
const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems));
|
||
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
|
||
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||
remainingItems -= takeItems;
|
||
} else {
|
||
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||
}
|
||
addAllocation(
|
||
openedSlot.slot,
|
||
booking.id,
|
||
booking.reference,
|
||
take,
|
||
AllocationLoadType.Bulk,
|
||
);
|
||
openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take);
|
||
remainingWeight = roundTons(remainingWeight - take);
|
||
placedAnywhere = true;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||
const usedSnapshot = new Map(
|
||
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
|
||
);
|
||
const slotCountSnapshot = openSlots.length;
|
||
const slotStateSnapshot = openSlots.map((open) => ({
|
||
teuPerEdge: [...open.teuPerEdge],
|
||
covered: { ...open.covered },
|
||
freeCapacityTons: open.freeCapacityTons,
|
||
freeItems: open.freeItems,
|
||
assignedWeightTons: open.slot.assignedWeightTons,
|
||
allocationCount: open.slot.allocations.length,
|
||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||
}));
|
||
|
||
const problem = tryPlaceBooking(booking);
|
||
if (!problem) {
|
||
fitting.push(booking);
|
||
continue;
|
||
}
|
||
|
||
// Roll back this booking's partial placements.
|
||
usedPerEdge.clear();
|
||
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
|
||
openSlots.length = slotCountSnapshot;
|
||
openSlots.forEach((open, index) => {
|
||
const snap = slotStateSnapshot[index];
|
||
if (!snap) return;
|
||
open.teuPerEdge = [...snap.teuPerEdge];
|
||
open.covered = { ...snap.covered };
|
||
open.freeCapacityTons = snap.freeCapacityTons;
|
||
open.freeItems = snap.freeItems;
|
||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||
open.slot.allocations.length = snap.allocationCount;
|
||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||
open.slot.allocations[allocationIndex].allocatedWeightTons = weight;
|
||
});
|
||
});
|
||
|
||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||
// Usage is rolled back here, so the shortage counts the stock this
|
||
// booking actually saw — not what its own partial placement consumed.
|
||
const bookingLeg = legFor(booking);
|
||
const shortage =
|
||
problem.kind === 'stock' && problem.candidates?.length
|
||
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
|
||
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
|
||
)
|
||
: null;
|
||
deferred.push({
|
||
id: booking.id,
|
||
reference: booking.reference,
|
||
reason: shortage
|
||
? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})`
|
||
: problem.message,
|
||
shortage,
|
||
});
|
||
}
|
||
|
||
return {
|
||
plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })),
|
||
fitting,
|
||
deferred,
|
||
configIssues: [...configIssues],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Reverse the wagon ORDER of a built plan when a schedule opts in.
|
||
*
|
||
* The plan comes out of planWagonsWithStock ordered by booking scheduling order
|
||
* (first slot opened = sequenceNo 1). When `reverse` is set, the physically-last
|
||
* wagon becomes wagon #1: the slot objects — and the bookings already allocated
|
||
* into each — travel WITH their slot, so only the position numbers flip. The
|
||
* physical composition, which booking is in which wagon, and every per-slot
|
||
* field are untouched; sequenceNo is renumbered 1..N over the reversed array.
|
||
*
|
||
* This single flip is the whole feature: persistTrainSetWagons writes these
|
||
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
|
||
* read them — so the stored train order and the schedule order stay identical,
|
||
* just reversed. A false/absent flag returns the plan unchanged.
|
||
*
|
||
* Only the NUMBERS flip — the array itself stays in packing order. Container
|
||
* placements are generated by walking the container units in booking order
|
||
* against getContainerSlotSequenceNos(plan) in array order, then matched back to
|
||
* their allocation by `sequenceNo:bookingId`. Reordering the array here broke
|
||
* that pairing on every reversed schedule: unit 1 was handed the number of the
|
||
* slot holding the LAST booking, the match missed, and persistAllocationsAndLoads
|
||
* silently dropped every container item — which is why a reversed export train
|
||
* printed a marshalling doc with no container numbers and 0/0 container counts.
|
||
*/
|
||
export function applyWagonOrderReversal(
|
||
plan: WagonPlanSlot[],
|
||
reverse: boolean | null | undefined,
|
||
): WagonPlanSlot[] {
|
||
if (!reverse) return plan;
|
||
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
|
||
}
|
||
|
||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
|
||
const remainingByTypeId = new Map<string, number>();
|
||
const codesByTypeId = new Map<string, string>();
|
||
for (const list of [
|
||
...allowed.byContainerTypeId.values(),
|
||
...allowed.byCargoTypeId.values(),
|
||
]) {
|
||
for (const wagonType of list) {
|
||
remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER);
|
||
codesByTypeId.set(wagonType.id, wagonType.code);
|
||
}
|
||
}
|
||
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||
}
|