fix issue

This commit is contained in:
Marshal
2026-08-02 10:26:36 +00:00
parent 53d8655dc3
commit ff772fddef
63 changed files with 10927 additions and 14 deletions

View File

@@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsForAllowedTypes,
} from './train-capacity.util';
import {
sortBookingsForScheduling,
type BookingWagonShortage,
@@ -64,6 +69,12 @@ type OpenSlot = {
/** 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
@@ -110,10 +121,19 @@ const shortageFor = (
booking.freightType === 'BULK'
? Math.max(
1,
Math.ceil(
Number(booking.cargoTotalWeightVgm ?? 0) /
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
),
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
// respected); PER_TON falls through to tonnage over the largest
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
// column is the item count, not tons.
bulkItemWagonsForAllowedTypes(
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 wagonsAvailable = candidates.reduce(
@@ -347,18 +367,64 @@ export function planWagonsWithStock(params: {
};
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
// 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;
/** Whole items one wagon of this slot's type can still take. */
const itemRoomOf = (open: OpenSlot): number =>
Math.min(
open.freeItems ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
);
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
const itemBudgetOf = (open: OpenSlot): number => {
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
const byTonnage =
perItemTons > 0
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
: 1;
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
};
let placedAnywhere = false;
// Per-item: prefer the type carrying the most whole items per wagon.
// openSlot's own capacity sort is stable, so this order breaks its ties.
const itemBudgetOfType = (wt: WagonType): number =>
Math.min(
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
perItemTons > 0
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
: 1,
);
const orderedCandidates = perItem
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
: candidates;
// Top off wagons already carrying THIS cargo type before opening new ones.
// ponytail: per-item cargo only shares wagons that were opened per-item
// (freeItems tracked); mixing itemized and loose loads of one cargo type
// on one wagon is not modeled — open a new wagon instead.
for (const open of openSlots) {
if (remainingWeight <= 0) break;
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue;
if (open.legKey !== legKey) continue;
if (open.cargoTypeId !== cargoTypeId) continue;
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
if (open.freeCapacityTons <= 0) continue;
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
if (perItem !== (open.freeItems !== undefined)) continue;
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
if (perItem && takeItems <= 0) continue;
const take = perItem
? roundTons(takeItems * perItemTons)
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
addAllocation(
open.slot,
booking.id,
@@ -367,14 +433,39 @@ export function planWagonsWithStock(params: {
AllocationLoadType.Bulk,
);
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
if (perItem) {
open.freeItems = (open.freeItems ?? 0) - takeItems;
remainingItems -= takeItems;
}
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
while (remainingWeight > 0 || !placedAnywhere) {
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
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,
);
if ('message' in openedSlot) return openedSlot;
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
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,
@@ -399,6 +490,7 @@ export function planWagonsWithStock(params: {
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),
@@ -420,6 +512,7 @@ export function planWagonsWithStock(params: {
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) => {