mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
811 lines
28 KiB
TypeScript
811 lines
28 KiB
TypeScript
import { AllocationLoadType } from '@edr/types';
|
||
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||
import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util';
|
||
|
||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||
export const MAX_TEU_SLOTS_PER_WAGON = 2;
|
||
|
||
export type TrainLimitConfig = {
|
||
maxWeightTons?: number;
|
||
maxLengthMeters?: number;
|
||
maxWagonsPerTrain?: number;
|
||
max20ftContainerWeightTons?: number;
|
||
max20ftPairWeightDiffTons?: number;
|
||
};
|
||
|
||
export type ContainerPlacementRules = {
|
||
max20ftContainerWeightTons?: number;
|
||
max20ftPairWeightDiffTons?: number;
|
||
};
|
||
|
||
export type WagonAllocationRecord = {
|
||
bookingId: string;
|
||
bookingReference: string;
|
||
allocatedWeightTons: number;
|
||
loadType: AllocationLoadType;
|
||
};
|
||
|
||
export type SlotLoadType = 'CONTAINER' | 'BULK';
|
||
|
||
export type WagonPlanSlot = {
|
||
sequenceNo: number;
|
||
wagonTypeId: string;
|
||
wagonTypeCode: string;
|
||
capacityTons: number;
|
||
lengthMeters: number;
|
||
/** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */
|
||
tareWeightTons: number;
|
||
/** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */
|
||
assignedWeightTons: number;
|
||
allocations: WagonAllocationRecord[];
|
||
slotLoadType?: SlotLoadType;
|
||
/**
|
||
* Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards
|
||
* at boardYardId and alights at alightYardId. Null = the schedule's own
|
||
* endpoint (whole-route slot, legacy behavior).
|
||
*/
|
||
boardYardId?: string | null;
|
||
alightYardId?: string | null;
|
||
};
|
||
|
||
export type ContainerUnitRow = {
|
||
bookingId: string;
|
||
bookingReference: string;
|
||
bookingContainerId: string;
|
||
unitIndex: number;
|
||
containerTypeId: string;
|
||
containerTypeCode: string;
|
||
label: string;
|
||
grossWeightTons: number;
|
||
sizeFt?: number;
|
||
containersPerWagon?: number;
|
||
teuSlots?: number;
|
||
containerNumber?: string | null;
|
||
};
|
||
|
||
export type ContainerPlacementInput = {
|
||
bookingContainerId: string;
|
||
unitIndex: number;
|
||
sequenceNo: number;
|
||
containerId?: string;
|
||
containerNumber?: string;
|
||
sealNumber?: string;
|
||
};
|
||
|
||
export function roundTons(value: number | string | null | undefined): number {
|
||
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
|
||
if (!Number.isFinite(numericValue)) return 0;
|
||
return Number(numericValue.toFixed(3));
|
||
}
|
||
|
||
/**
|
||
* Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill;
|
||
* a missing tare must read as 0 rather than silently inventing dead weight.
|
||
*/
|
||
export function tareTonsOf(wagonType: Pick<WagonType, 'tareWeightTons'>): number {
|
||
return roundTons(wagonType.tareWeightTons ?? 0);
|
||
}
|
||
|
||
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||
return sizeFt >= 40 ? 2 : 1;
|
||
}
|
||
|
||
type ContainerLine = {
|
||
quantity?: number | null;
|
||
wagonsRequired?: number | null;
|
||
containerType?: { sizeFt?: number | null } | null;
|
||
};
|
||
|
||
/**
|
||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
|
||
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
|
||
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
|
||
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
|
||
* wagons, not 4).
|
||
*/
|
||
function lineWagonsRaw(line: ContainerLine): number {
|
||
const qty = Number(line.quantity ?? 0);
|
||
if (qty <= 0) return 0;
|
||
const sizeFt = Number(line.containerType?.sizeFt);
|
||
if (Number.isFinite(sizeFt) && sizeFt > 0) {
|
||
return qty * wagonsPerUnitForSize(sizeFt);
|
||
}
|
||
// No size on the type: fall back to the line's stored fraction, else treat
|
||
// the whole line as one wagon.
|
||
const stored = Number(line.wagonsRequired);
|
||
return Number.isFinite(stored) && stored > 0 ? stored : 1;
|
||
}
|
||
|
||
/**
|
||
* Whole wagons a set of container lines needs: ceil the summed RAW fraction so a
|
||
* half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0.
|
||
*/
|
||
export function containerWagonsForLines(lines: ContainerLine[]): number {
|
||
const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0);
|
||
return raw > 0 ? Math.ceil(raw) : 0;
|
||
}
|
||
|
||
/**
|
||
* Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required.
|
||
*/
|
||
export function buildContainerWagonPlan(
|
||
bookings: Booking[],
|
||
wagonType: WagonType,
|
||
): WagonPlanSlot[] {
|
||
// Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit
|
||
// can share a wagon with another 20ft of the SAME booking, never across
|
||
// bookings), then sum. Ceiling per line instead would over-count a booking
|
||
// that splits its 20ft units across several lines.
|
||
const totalSlots = bookings.reduce((sum, booking) => {
|
||
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
|
||
return sum + Math.max(bookingSlots, 1);
|
||
}, 0);
|
||
|
||
const slots = Math.max(1, totalSlots);
|
||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||
sequenceNo: index + 1,
|
||
wagonTypeId: wagonType.id,
|
||
wagonTypeCode: wagonType.code,
|
||
capacityTons: Number(wagonType.capacityTons),
|
||
lengthMeters: Number(wagonType.lengthMeters),
|
||
tareWeightTons: tareTonsOf(wagonType),
|
||
assignedWeightTons: 0,
|
||
allocations: [],
|
||
}));
|
||
|
||
return allocateContainersToSlots(bookings, basePlan).map((slot) => ({
|
||
...slot,
|
||
slotLoadType: 'CONTAINER' as SlotLoadType,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Build weight-based wagon plan for BULK bookings.
|
||
*/
|
||
export function buildBulkWagonPlan(
|
||
bookings: Booking[],
|
||
wagonType: WagonType,
|
||
): WagonPlanSlot[] {
|
||
const capacity = Number(wagonType.capacityTons);
|
||
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
|
||
// their tonnage must NOT pool with PER_TON cargo (an item can't split
|
||
// across wagons the way loose tonnage can).
|
||
const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity));
|
||
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||
const totalWeight = roundTons(
|
||
bookings.reduce(
|
||
(sum, b, i) =>
|
||
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||
0,
|
||
),
|
||
);
|
||
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
|
||
const slots = Math.max(1, tonSlots + itemSlots);
|
||
|
||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||
sequenceNo: index + 1,
|
||
wagonTypeId: wagonType.id,
|
||
wagonTypeCode: wagonType.code,
|
||
capacityTons: capacity,
|
||
lengthMeters: Number(wagonType.lengthMeters),
|
||
tareWeightTons: tareTonsOf(wagonType),
|
||
assignedWeightTons: 0,
|
||
allocations: [],
|
||
}));
|
||
|
||
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({
|
||
...slot,
|
||
slotLoadType: 'BULK' as SlotLoadType,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers.
|
||
*/
|
||
export function buildMixedWagonPlan(
|
||
containerBookings: Booking[],
|
||
bulkBookings: Booking[],
|
||
containerWagonType: WagonType,
|
||
bulkWagonType: WagonType,
|
||
): WagonPlanSlot[] {
|
||
const containerPlan = containerBookings.length
|
||
? buildContainerWagonPlan(containerBookings, containerWagonType)
|
||
: [];
|
||
const bulkPlan = bulkBookings.length
|
||
? buildBulkWagonPlan(bulkBookings, bulkWagonType)
|
||
: [];
|
||
|
||
const tagged: WagonPlanSlot[] = [
|
||
...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })),
|
||
...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })),
|
||
];
|
||
|
||
if (!tagged.length) {
|
||
return [
|
||
{
|
||
sequenceNo: 1,
|
||
wagonTypeId: containerWagonType.id,
|
||
wagonTypeCode: containerWagonType.code,
|
||
capacityTons: Number(containerWagonType.capacityTons),
|
||
lengthMeters: Number(containerWagonType.lengthMeters),
|
||
tareWeightTons: tareTonsOf(containerWagonType),
|
||
assignedWeightTons: 0,
|
||
allocations: [],
|
||
slotLoadType: 'CONTAINER',
|
||
},
|
||
];
|
||
}
|
||
|
||
return tagged.map((slot, index) => ({
|
||
...slot,
|
||
sequenceNo: index + 1,
|
||
}));
|
||
}
|
||
|
||
export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] {
|
||
const rows: ContainerUnitRow[] = [];
|
||
|
||
for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) {
|
||
for (const line of booking.bookingContainers ?? []) {
|
||
const qty = Number(line.quantity ?? 0);
|
||
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
||
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
||
const perWagon = containersPerWagonForSize(sizeFt);
|
||
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||
// The REAL per-container numbers/weights entered at booking time. Unit i of
|
||
// the line maps to units[i] (sortOrder order); the line-level number is only
|
||
// a legacy fallback — never invent numbers here.
|
||
const units = [...(line.units ?? [])].sort(
|
||
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
|
||
);
|
||
for (let i = 0; i < qty; i += 1) {
|
||
const unit = units[i];
|
||
rows.push({
|
||
bookingId: booking.id,
|
||
bookingReference: booking.reference,
|
||
bookingContainerId: line.id,
|
||
unitIndex: i,
|
||
containerTypeId: line.containerTypeId ?? '',
|
||
containerTypeCode: code,
|
||
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
|
||
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
|
||
sizeFt,
|
||
containersPerWagon: perWagon,
|
||
teuSlots,
|
||
containerNumber:
|
||
unit?.containerNumber?.trim() || line.containerNumber || null,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] {
|
||
return wagonPlan
|
||
.filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some(
|
||
(a) => a.loadType === AllocationLoadType.Container,
|
||
))
|
||
.map((slot) => slot.sequenceNo);
|
||
}
|
||
|
||
function allocateBookingsToSlots(
|
||
bookings: Booking[],
|
||
basePlan: WagonPlanSlot[],
|
||
loadType: AllocationLoadType,
|
||
): WagonPlanSlot[] {
|
||
const remaining = bookings.map((booking) => ({
|
||
bookingId: booking.id,
|
||
bookingReference: booking.reference,
|
||
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
|
||
// bookings that column is an item COUNT, not tons.
|
||
remainingWeightTons: roundTons(bookingCargoTons(booking)),
|
||
}));
|
||
|
||
let bookingIndex = 0;
|
||
|
||
return basePlan.map((slot) => {
|
||
let wagonRemaining = roundTons(slot.capacityTons);
|
||
const allocations: WagonAllocationRecord[] = [];
|
||
let assignedWeightTons = 0;
|
||
|
||
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
||
const booking = remaining[bookingIndex];
|
||
const allocatedWeightTons = roundTons(
|
||
Math.min(wagonRemaining, booking.remainingWeightTons),
|
||
);
|
||
|
||
if (allocatedWeightTons <= 0) {
|
||
bookingIndex += 1;
|
||
continue;
|
||
}
|
||
|
||
allocations.push({
|
||
bookingId: booking.bookingId,
|
||
bookingReference: booking.bookingReference,
|
||
allocatedWeightTons,
|
||
loadType,
|
||
});
|
||
|
||
booking.remainingWeightTons = roundTons(
|
||
booking.remainingWeightTons - allocatedWeightTons,
|
||
);
|
||
wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons);
|
||
assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons);
|
||
|
||
if (booking.remainingWeightTons <= 0) {
|
||
bookingIndex += 1;
|
||
}
|
||
}
|
||
|
||
return { ...slot, assignedWeightTons, allocations };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most
|
||
* 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU
|
||
* each) — a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container
|
||
* maps to a real wagon allocation, and this mirrors the frontend auto-fill packing
|
||
* exactly so a placement's sequenceNo always lands on a slot that holds an allocation
|
||
* for its booking.
|
||
*
|
||
* Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses
|
||
* several light containers into the first wagons by tonnage and leaves later container
|
||
* units without an allocation slot, which silently drops their container items on assign.
|
||
*/
|
||
function allocateContainersToSlots(
|
||
bookings: Booking[],
|
||
basePlan: WagonPlanSlot[],
|
||
): WagonPlanSlot[] {
|
||
const slots = basePlan.map((slot) => ({
|
||
...slot,
|
||
assignedWeightTons: 0,
|
||
allocations: [] as WagonAllocationRecord[],
|
||
}));
|
||
if (!slots.length) return slots;
|
||
|
||
const units = expandBookingContainerUnits(bookings);
|
||
let currentSlotIndex = 0;
|
||
let teuInCurrentSlot = 0;
|
||
|
||
for (const unit of units) {
|
||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||
|
||
// Move to the next wagon once this one can't fit the container's TEU. This keeps a
|
||
// 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft.
|
||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||
currentSlotIndex += 1;
|
||
teuInCurrentSlot = 0;
|
||
}
|
||
|
||
const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!;
|
||
|
||
let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId);
|
||
if (!allocation) {
|
||
allocation = {
|
||
bookingId: unit.bookingId,
|
||
bookingReference: unit.bookingReference,
|
||
allocatedWeightTons: 0,
|
||
loadType: AllocationLoadType.Container,
|
||
};
|
||
slot.allocations.push(allocation);
|
||
}
|
||
allocation.allocatedWeightTons = roundTons(
|
||
allocation.allocatedWeightTons + unit.grossWeightTons,
|
||
);
|
||
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons);
|
||
teuInCurrentSlot += teu;
|
||
}
|
||
|
||
return slots;
|
||
}
|
||
|
||
export function expandContainerItems(
|
||
booking: Booking,
|
||
allocationId: string,
|
||
): Array<{
|
||
wagonBookingAllocationId: string;
|
||
bookingContainerId: string;
|
||
containerTypeId: string;
|
||
grossWeightTons: number;
|
||
positionOnWagon: number | null;
|
||
}> {
|
||
const items: Array<{
|
||
wagonBookingAllocationId: string;
|
||
bookingContainerId: string;
|
||
containerTypeId: string;
|
||
grossWeightTons: number;
|
||
positionOnWagon: number | null;
|
||
}> = [];
|
||
|
||
for (const line of booking.bookingContainers ?? []) {
|
||
const qty = Number(line.quantity ?? 0);
|
||
for (let i = 0; i < qty; i += 1) {
|
||
items.push({
|
||
wagonBookingAllocationId: allocationId,
|
||
bookingContainerId: line.id,
|
||
containerTypeId: line.containerTypeId ?? '',
|
||
grossWeightTons: Number(line.vgmPerUnitTons),
|
||
positionOnWagon: qty > 1 ? i + 1 : null,
|
||
});
|
||
}
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* Wagons a booking actually occupies. Prefer counting the built wagon plan's
|
||
* slots that carry one of the booking's allocations — for BULK that is its
|
||
* tonnage spread over real wagons (a 700T booking on 70T wagons rides 10
|
||
* wagons, and downstream gross-weight math charges 10 tares, not 1). Without
|
||
* a plan there is no capacity to divide by, so fall back to the pre-plan
|
||
* estimates: 1 for bulk, the lines' stored counts for containers.
|
||
*/
|
||
export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]): number {
|
||
const occupiedSlots = (wagonPlan ?? []).filter((slot) =>
|
||
slot.allocations.some((allocation) => allocation.bookingId === booking.id),
|
||
).length;
|
||
if (occupiedSlots > 0) {
|
||
return occupiedSlots;
|
||
}
|
||
if (booking.freightType === 'BULK') {
|
||
return 1;
|
||
}
|
||
return (booking.bookingContainers ?? []).reduce(
|
||
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
|
||
0,
|
||
);
|
||
}
|
||
|
||
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
|
||
const violations: string[] = [];
|
||
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
|
||
if (slot.assignedWeightTons > slot.capacityTons) {
|
||
violations.push(
|
||
`Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`,
|
||
);
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Check a consist against its train's three limits. Weight is GROSS — every slot
|
||
* contributes its own tare plus the cargo assigned to it — because the locomotive
|
||
* pull limit governs what it drags, not what was sold. Length and tare are summed
|
||
* per slot, so a mixed consist is measured as it actually stands rather than
|
||
* through one representative wagon type.
|
||
*
|
||
* `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain`
|
||
* is absent; slot dimensions always win over it.
|
||
*/
|
||
export function validateTrainLimits(
|
||
wagonPlan: WagonPlanSlot[],
|
||
wagonType: Pick<WagonType, 'lengthMeters'>,
|
||
limits?: TrainLimitConfig,
|
||
): string[] {
|
||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||
const maxWagonSlots =
|
||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
|
||
|
||
const violations = consistViolations(
|
||
wagonPlan.map((slot) => ({
|
||
lengthMeters: Number(slot.lengthMeters),
|
||
tareWeightTons: Number(slot.tareWeightTons ?? 0),
|
||
cargoTons: Number(slot.assignedWeightTons),
|
||
})),
|
||
{ maxWeightTons, maxLengthMeters, maxWagonSlots },
|
||
);
|
||
|
||
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Mixed consist: the wagon-count fallback uses the shortest type present, since
|
||
* that is the most wagons that could ever fit. Weight and length still come from
|
||
* the slots themselves.
|
||
*/
|
||
export function validateMixedTrainLimits(
|
||
wagonPlan: WagonPlanSlot[],
|
||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||
limits?: TrainLimitConfig,
|
||
): string[] {
|
||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||
const minWagonLength = Math.min(
|
||
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
|
||
14,
|
||
);
|
||
const maxWagonsPerTrain =
|
||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
|
||
|
||
return validateTrainLimits(
|
||
wagonPlan,
|
||
{ lengthMeters: minWagonLength },
|
||
{ ...limits, maxWagonsPerTrain },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Leg-aware limit check: with a real stop list, a slot only counts on the
|
||
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
|
||
* own endpoint). Each edge is validated as its own consist, so an intercity
|
||
* wagon on Gelan→Adama never counts against a train that is full only on
|
||
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
|
||
*/
|
||
export function validateMixedTrainLimitsPerEdge(
|
||
wagonPlan: WagonPlanSlot[],
|
||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||
limits: TrainLimitConfig | undefined,
|
||
stops: string[],
|
||
): string[] {
|
||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||
const spans = slotSpans(wagonPlan, stops);
|
||
const violations = new Set<string>();
|
||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||
const active = wagonPlan.filter(
|
||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||
);
|
||
if (!active.length) continue;
|
||
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||
violations.add(violation);
|
||
}
|
||
}
|
||
return [...violations];
|
||
}
|
||
|
||
/**
|
||
* The slot fields per-edge usage math actually reads — lets callers feed
|
||
* persisted TrainSetWagon rows (or any structural subset), not only plan slots.
|
||
*/
|
||
export type EdgeUsageSlot = Pick<
|
||
WagonPlanSlot,
|
||
'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons'
|
||
> & {
|
||
boardYardId?: string | null;
|
||
alightYardId?: string | null;
|
||
allocations?: unknown[];
|
||
};
|
||
|
||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||
function slotSpans(
|
||
wagonPlan: EdgeUsageSlot[],
|
||
stops: string[],
|
||
): Array<{ from: number; to: number }> {
|
||
const lastIdx = stops.length - 1;
|
||
return wagonPlan.map((slot) => {
|
||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
|
||
* summed over only the slots riding each edge, maxed across edges. This is the
|
||
* figure a locomotive pull/length limit must be compared against — a train is
|
||
* never heavier than its heaviest single leg, so summing disjoint legs
|
||
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
|
||
* Two stops or fewer degrade to the whole-train totals.
|
||
*/
|
||
export function maxEdgeConsistUsage(
|
||
wagonPlan: EdgeUsageSlot[],
|
||
stops: string[],
|
||
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
||
const totals = (slots: EdgeUsageSlot[]) => ({
|
||
grossWeightTons: slots.reduce(
|
||
(sum, w) =>
|
||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||
0,
|
||
),
|
||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
|
||
});
|
||
if (stops.length <= 2) return totals(wagonPlan);
|
||
const spans = slotSpans(wagonPlan, stops);
|
||
const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 };
|
||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||
const active = totals(
|
||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
||
);
|
||
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
|
||
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
|
||
usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount);
|
||
}
|
||
return usage;
|
||
}
|
||
|
||
export function validate20ftContainerRules(
|
||
units: ContainerUnitRow[],
|
||
placements: ContainerPlacementInput[],
|
||
rules?: ContainerPlacementRules,
|
||
): string[] {
|
||
const violations: string[] = [];
|
||
const maxEach = rules?.max20ftContainerWeightTons;
|
||
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||
if (maxEach == null && maxDiff == null) return violations;
|
||
|
||
const placementByUnit = new Map(
|
||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||
);
|
||
|
||
const weightsBySlot = new Map<number, number[]>();
|
||
|
||
for (const unit of units) {
|
||
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
||
if (sizeFt >= 40) continue;
|
||
|
||
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||
violations.push(
|
||
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
|
||
);
|
||
}
|
||
|
||
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||
if (!placement?.sequenceNo) continue;
|
||
|
||
const list = weightsBySlot.get(placement.sequenceNo) ?? [];
|
||
list.push(unit.grossWeightTons);
|
||
weightsBySlot.set(placement.sequenceNo, list);
|
||
}
|
||
|
||
if (maxDiff != null) {
|
||
for (const [sequenceNo, weights] of weightsBySlot.entries()) {
|
||
if (weights.length < 2) continue;
|
||
const diff = Math.abs(weights[0]! - weights[1]!);
|
||
if (diff > maxDiff) {
|
||
violations.push(
|
||
`Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return violations;
|
||
}
|
||
|
||
export function validateContainerPlacements(
|
||
containerBookings: Booking[],
|
||
wagonPlan: WagonPlanSlot[],
|
||
placements: ContainerPlacementInput[],
|
||
rules?: ContainerPlacementRules,
|
||
/**
|
||
* Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg.
|
||
* With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity
|
||
* 20ft and an export 20ft coexist on one wagon when their edges allow it.
|
||
* Omitted → one edge, byte-identical to the whole-route check.
|
||
*/
|
||
legs?: Map<string, { from: number; to: number }>,
|
||
edgeCount?: number,
|
||
): string[] {
|
||
const violations: string[] = [];
|
||
const units = expandBookingContainerUnits(containerBookings);
|
||
if (!units.length) return violations;
|
||
|
||
const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan));
|
||
const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`));
|
||
const placementKeys = new Set<string>();
|
||
const containerNumbers = new Set<string>();
|
||
|
||
if (!placements.length) {
|
||
violations.push('Container placements are required for container bookings');
|
||
return violations;
|
||
}
|
||
|
||
for (const placement of placements) {
|
||
const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`;
|
||
if (!unitKeys.has(unitKey)) {
|
||
violations.push(
|
||
`Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`,
|
||
);
|
||
continue;
|
||
}
|
||
if (placementKeys.has(unitKey)) {
|
||
violations.push(`Duplicate placement for container unit ${unitKey}`);
|
||
}
|
||
placementKeys.add(unitKey);
|
||
|
||
if (!containerSlots.has(placement.sequenceNo)) {
|
||
violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`);
|
||
}
|
||
|
||
const hasInventory = Boolean(placement.containerId);
|
||
const hasManual = Boolean(placement.containerNumber?.trim());
|
||
if (!hasInventory && !hasManual) {
|
||
violations.push(
|
||
`Container unit ${unitKey} requires an existing container or a new container number`,
|
||
);
|
||
}
|
||
|
||
if (hasManual) {
|
||
const normalized = placement.containerNumber!.trim().toUpperCase();
|
||
if (containerNumbers.has(normalized)) {
|
||
violations.push(`Duplicate container number ${normalized}`);
|
||
}
|
||
containerNumbers.add(normalized);
|
||
}
|
||
}
|
||
|
||
for (const unit of units) {
|
||
const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`;
|
||
if (!placementKeys.has(unitKey)) {
|
||
violations.push(`Missing placement for ${unit.label}`);
|
||
}
|
||
}
|
||
|
||
// TEU and weight are tracked PER EDGE of a unit's leg; without legs there is
|
||
// a single edge and this is exactly the old whole-route accounting.
|
||
const edges = Math.max(1, edgeCount ?? 1);
|
||
const legOf = (bookingId: string): { from: number; to: number } => {
|
||
const leg = legs?.get(bookingId);
|
||
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
|
||
return { from: 0, to: edges };
|
||
}
|
||
return leg;
|
||
};
|
||
const slotTeuUsed = new Map<number, number[]>();
|
||
const slotWeightUsed = new Map<number, number[]>();
|
||
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
|
||
|
||
for (const placement of placements) {
|
||
const unit = units.find(
|
||
(u) =>
|
||
u.bookingContainerId === placement.bookingContainerId &&
|
||
u.unitIndex === placement.unitIndex,
|
||
);
|
||
if (!unit) continue;
|
||
|
||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||
const leg = legOf(unit.bookingId);
|
||
const teuRow =
|
||
slotTeuUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
|
||
let teuFits = true;
|
||
for (let e = leg.from; e < leg.to; e += 1) {
|
||
if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||
teuFits = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!teuFits) {
|
||
violations.push(
|
||
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
|
||
);
|
||
} else {
|
||
for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu;
|
||
slotTeuUsed.set(placement.sequenceNo, teuRow);
|
||
}
|
||
|
||
const slot = slotBySeq.get(placement.sequenceNo);
|
||
if (slot) {
|
||
const weightRow =
|
||
slotWeightUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
|
||
let heaviestEdge = 0;
|
||
for (let e = leg.from; e < leg.to; e += 1) {
|
||
weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons);
|
||
heaviestEdge = Math.max(heaviestEdge, weightRow[e]);
|
||
}
|
||
slotWeightUsed.set(placement.sequenceNo, weightRow);
|
||
if (heaviestEdge > slot.capacityTons) {
|
||
violations.push(
|
||
`Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
violations.push(...validate20ftContainerRules(units, placements, rules));
|
||
|
||
return violations;
|
||
}
|