This commit is contained in:
Marshal
2026-08-22 00:09:00 +00:00
parent bf4d0632f1
commit bad418d79e
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) {