mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1385 from Tria-plc/freight_feature/usermanagement
changes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user