Merge pull request #1385 from Tria-plc/freight_feature/usermanagement

changes
This commit is contained in:
marshal
2026-08-22 03:10:29 +03:00
committed by GitHub
5 changed files with 657 additions and 28 deletions

View File

@@ -130,6 +130,7 @@ import {
maxEdgeConsistUsage,
perEdgeConsistUsage,
validateContainerPlacements,
validateWagonCargoExclusivity,
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
@@ -2200,12 +2201,23 @@ export class TrainSchedulingService {
].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId }));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
// Leg spans for the per-edge exclusivity guard — a wagon may carry
// containers to Dire Dawa and bulk onward, never both at once.
const persistLegs = new Map(
bookings.flatMap((b) => {
const from = scheduleStops.indexOf(b.originYardId);
const to = scheduleStops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
await this.persistAllocationsAndLoads(
manager,
savedWagons,
wagonPlan,
bookings,
containerPlacements ?? [],
persistLegs,
Math.max(1, scheduleStops.length - 1),
);
// The link above puts these bookings on the train: they are SCHEDULED, not
@@ -5023,6 +5035,9 @@ export class TrainSchedulingService {
trainLimits,
stops,
stopLabels,
// Cargo exclusivity is a per-edge rule: a wagon may carry containers
// to Dire Dawa and bulk onward from there, never both at once.
legByBookingId,
),
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
@@ -6048,6 +6063,9 @@ export class TrainSchedulingService {
wagonPlan: WagonPlanSlot[],
bookings: Booking[],
containerPlacements: ContainerPlacementInput[] = [],
/** Booking id → stop-index span, for the per-edge exclusivity guard. */
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
) {
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const lineById = new Map(
@@ -6081,16 +6099,18 @@ export class TrainSchedulingService {
const trainSetWagon = savedWagons[i];
if (!slot || !trainSetWagon) continue;
// Last line of defense behind validateWagonCargoExclusivity: a wagon
// with bulk on it carries that one load only — never a container and
// never a second bulk booking.
if (
slot.allocations.length > 1 &&
slot.allocations.some((a) => a.loadType === AllocationLoadType.Bulk)
) {
throw new BadRequestException(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
);
// Last line of defense behind validateWagonCargoExclusivity: while a
// bulk load rides, its wagon carries nothing else — no container and no
// second bulk booking. Loads on DISJOINT legs (a container that alights
// where the bulk boards) legitimately share the wagon, so the check is
// per corridor edge, using the same leg spans the plan was built with.
const exclusivityIssues = validateWagonCargoExclusivity(
[slot],
legs,
edgeCount,
);
if (exclusivityIssues.length) {
throw new BadRequestException(exclusivityIssues[0]);
}
for (const alloc of slot.allocations) {

View File

@@ -502,20 +502,48 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[])
}
/**
* One wagon carries one kind of cargo: a slot with a BULK allocation holds
* nothing else — no container beside it and no second bulk booking. Container
* allocations may still share a wagon with each other (TEU rules apply).
* One wagon carries one kind of cargo AT A TIME: while a bulk load rides, the
* wagon holds nothing else — no container beside it and no second bulk
* booking. Container allocations may share a wagon with each other (TEU rules
* apply).
*
* "At a time" is the whole rule: a wagon whose cargo alights at Dire Dawa is
* empty steel for whatever boards there, so an import container on
* Doraleh→Dire and bulk on Dire→Kality legitimately share one wagon. Pass
* `legs` (booking id → stop-index span) to check per corridor edge; without
* it every allocation is treated as riding the whole route, which is the
* correct reading for a single-leg train.
*/
export function validateWagonCargoExclusivity(wagonPlan: WagonPlanSlot[]): string[] {
export function validateWagonCargoExclusivity(
wagonPlan: WagonPlanSlot[],
legs?: Map<string, { from: number; to: number }>,
edgeCount = 1,
): string[] {
const violations: string[] = [];
const edges = Math.max(1, edgeCount);
const spanOf = (bookingId: string) => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
for (const slot of wagonPlan) {
const hasBulk = slot.allocations.some(
(a) => a.loadType === AllocationLoadType.Bulk,
);
if (hasBulk && slot.allocations.length > 1) {
violations.push(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
);
if (slot.allocations.length < 2) continue;
// Per edge: who is on this wagon while it rides that edge?
for (let edge = 0; edge < edges; edge += 1) {
const riding = slot.allocations.filter((a) => {
const span = spanOf(a.bookingId);
return span.from <= edge && edge < span.to;
});
if (riding.length < 2) continue;
if (riding.some((a) => a.loadType === AllocationLoadType.Bulk)) {
violations.push(
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
);
break;
}
}
}
return violations;
@@ -547,6 +575,9 @@ export function validateTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig,
/** Leg-aware cargo exclusivity — see {@link validateWagonCargoExclusivity}. */
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
@@ -564,7 +595,7 @@ export function validateTrainLimits(
);
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
violations.push(...validateWagonCargoExclusivity(wagonPlan));
violations.push(...validateWagonCargoExclusivity(wagonPlan, legs, edgeCount));
return violations;
}
@@ -578,6 +609,8 @@ export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig,
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const minWagonLength = Math.min(
@@ -591,6 +624,8 @@ export function validateMixedTrainLimits(
wagonPlan,
{ lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain },
legs,
edgeCount,
);
}
@@ -608,8 +643,13 @@ export function validateMixedTrainLimitsPerEdge(
stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[],
/** Booking id → stop-index span, so cargo exclusivity is judged per edge. */
legs?: Map<string, { from: number; to: number }>,
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const edges = Math.max(1, stops.length - 1);
if (stops.length <= 2) {
return validateMixedTrainLimits(wagonPlan, wagonTypes, limits, legs, edges);
}
const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
@@ -618,7 +658,13 @@ export function validateMixedTrainLimitsPerEdge(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
for (const violation of validateMixedTrainLimits(
active,
wagonTypes,
limits,
legs,
edges,
)) {
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
}
}

View File

@@ -416,8 +416,14 @@ export function planWagonsWithStock(params: {
}
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' &&
(open.kind === 'CONTAINER' || disjointFrom(open)) &&
allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) &&
canExtendSpan(open, leg);
@@ -457,6 +463,7 @@ export function planWagonsWithStock(params: {
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,
@@ -500,9 +507,61 @@ export function planWagonsWithStock(params: {
)
: candidates;
// One bulk booking per wagon: a wagon carrying bulk takes that one
// booking's cargo only — never topped up from another booking, even of
// the same cargo type. Every bulk booking therefore opens its own wagons.
// 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