fix issue

This commit is contained in:
Marshal
2026-08-22 01:23:50 +00:00
parent b6c1efa043
commit 1ab2cfdb3b
13 changed files with 874 additions and 109 deletions

View File

@@ -136,7 +136,11 @@ import {
type ContainerPlacementInput,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
import { CorridorBudget } from '../corridor-capacity.util';
import {
CorridorBudget,
orientStopsToSchedule,
subtractCutWagons,
} from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import {
@@ -200,7 +204,7 @@ import {
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
occupiedTeuBySlot,
occupiedTeuPerEdgeBySlot,
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
@@ -1987,22 +1991,89 @@ export class TrainSchedulingService {
(b) => b.freightType === 'CONTAINER',
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
// Leg-aware fill: each unit carries its booking's stop-index span and
// each slot the span it rides, so TEU is counted per corridor edge —
// the same accounting the planner and the placement validator use. A
// whole-route walk on a leg-sharing train believed every wagon was
// full after one 40ft on ANY leg and dumped the leftovers onto the
// last wagon (#42), producing a wall of per-unit violations.
const previewStops = await this.stopYardsForSchedule(schedule);
const edgeCount = Math.max(1, previewStops.length - 1);
const legOfBooking = new Map(
preview.bookings.map((b) => {
const from = previewStops.indexOf(b.originYardId);
const to = previewStops.indexOf(b.destinationYardId);
return [
b.id,
from >= 0 && to > from ? { from, to } : { from: 0, to: edgeCount },
] as const;
}),
);
const units = expandBookingContainerUnits(containerBookings).map((u) => ({
...u,
leg: legOfBooking.get(u.bookingId),
}));
const slotSpans = preview.wagonPlan
.filter(
(s) =>
s.slotLoadType === 'CONTAINER' ||
s.allocations.some((a) => a.loadType === AllocationLoadType.Container),
)
.map((s) => {
const from = s.boardYardId ? previewStops.indexOf(s.boardYardId) : 0;
const toIdx = s.alightYardId ? previewStops.indexOf(s.alightYardId) : -1;
return {
sequenceNo: s.sequenceNo,
from: from >= 0 ? from : 0,
to: toIdx > 0 ? toIdx : edgeCount,
};
});
// Caller-provided placements can be STALE: the workspace pins container
// positions against the plan it last fetched, and every (re)assignment
// rebuilds the plan with fresh sequence numbers (remove + re-add being
// the common case). A pin pointing at a slot that no longer exists must
// not poison the fill — drop it and auto-place its unit instead; the
// placement validator still checks whatever survives.
const validSeq = new Set(slotSpans.map((s) => s.sequenceNo));
const provided = (containerPlacements ?? []).filter((p) =>
validSeq.has(p.sequenceNo),
);
const droppedStale = (containerPlacements ?? []).length - provided.length;
if (droppedStale > 0) {
this.logger.warn(
`[assign ${scheduleId}] dropped ${droppedStale} stale container placement(s) ` +
`pointing at slots not in the rebuilt plan — re-auto-filling those units`,
);
}
const providedKeys = new Set(
(containerPlacements ?? []).map(
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
),
provided.map((p) => `${p.bookingContainerId}:${p.unitIndex}`),
);
const unplacedUnits = units.filter(
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(
if (unplacedUnits.length || droppedStale > 0) {
const { placements: generated, overflow } = autoFillPlacements(
unplacedUnits,
slots,
occupiedTeuBySlot(containerPlacements ?? [], units),
slotSpans,
occupiedTeuPerEdgeBySlot(provided, units, edgeCount),
edgeCount,
);
if (overflow.length) {
// One honest message, grouped per booking — not one violation per
// container piled onto the same wagon.
const byRef = new Map<string, number>();
for (const u of overflow) {
const ref = u.bookingReference ?? u.bookingId;
byRef.set(ref, (byRef.get(ref) ?? 0) + 1);
}
const detail = [...byRef.entries()]
.map(([ref, n]) => `${ref}: ${n} container(s) have no wagon space left`)
.join('; ');
throw new BadRequestException({
message: `Booking validation failed: ${detail} — the train's container wagons are full on the booking's leg`,
violations: [detail],
});
}
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
@@ -2012,7 +2083,7 @@ export class TrainSchedulingService {
violations: missing.map((m) => m.issue),
});
}
containerPlacements = [...(containerPlacements ?? []), ...generated];
containerPlacements = [...provided, ...generated];
}
}
}
@@ -4328,6 +4399,38 @@ export class TrainSchedulingService {
const passedYardIds = stations
.filter((s) => s.sequenceNo <= dto.sequenceNo)
.map((s) => s.yardId);
// Wagons planned to CUT at a stop the train has now passed detach
// here: position freezes at the cut yard and they stop riding the
// position fix below (its filter is current_train_schedule_id).
// Cargo-carrying ones were already settled by autoUnloadAtYard above
// (validation forbids cargo booked past the cut) — anything still
// bound to the schedule is riding empty. Matching against ALL passed
// yards, not just this one, self-heals skipped checkpoint logs.
const cutPlan = schedule.plannedWagonCutYards ?? {};
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
passedYardIds.includes(yardId),
);
for (const [wagonId, cutYardId] of cutNow) {
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
await manager.getRepository(Wagon).update(wagonId, {
currentYardId: cutYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
});
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId,
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId,
toYardId: cutYardId,
trainScheduleId: scheduleId,
kind: WagonMovementKind.EmptyReposition,
occurredAt,
}),
);
}
await manager
.getRepository(Wagon)
.createQueryBuilder()
@@ -4546,9 +4649,14 @@ export class TrainSchedulingService {
// A wagon that already alighted mid-route (unload released it, possibly
// re-pinned elsewhere since) is no longer this schedule's to move.
if (wagon.currentTrainScheduleId !== scheduleId) continue;
// Dynamic consist: the wagon settles at its slot's alight yard, not
// blanket at the train's destination.
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
// Dynamic consist: the wagon settles at its planned cut yard first,
// then its slot's alight yard — never blanket at the train's
// destination. Covers journeys logged with only a final arrival: cut
// wagons still settle at their cut yard instead of teleporting to it.
const settleYardId =
schedule.plannedWagonCutYards?.[wagon.id] ??
slot.alightYardId ??
schedule.destinationStationId;
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
@@ -5566,6 +5674,7 @@ export class TrainSchedulingService {
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
const cutPlan = pinSchedule?.plannedWagonCutYards ?? {};
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
@@ -5576,6 +5685,7 @@ export class TrainSchedulingService {
pinnedToScheduleIds,
stops,
plannedYards,
cutPlan,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -5598,6 +5708,8 @@ export class TrainSchedulingService {
pinnedToScheduleIds,
reverseWagonOrder,
plannedYards,
cutPlan,
stops,
);
if (!physical) continue;
@@ -5648,6 +5760,7 @@ export class TrainSchedulingService {
pinnedToScheduleIds,
stops,
targetSchedule?.plannedWagonYards ?? {},
targetSchedule?.plannedWagonCutYards ?? {},
);
}
@@ -5681,6 +5794,7 @@ export class TrainSchedulingService {
pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [],
plannedYards: PlannedWagonYards = {},
cutPlan: Record<string, string> = {},
): string[] {
const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap
@@ -5701,6 +5815,8 @@ export class TrainSchedulingService {
pinnedToScheduleIds,
false,
plannedYards,
cutPlan,
stops,
);
if (!physical) {
violations.push(
@@ -5732,7 +5848,17 @@ export class TrainSchedulingService {
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
plannedYards: PlannedWagonYards = {},
cutPlan: Record<string, string> = {},
stops: string[] = [],
): Wagon | undefined {
// How far down the route a wagon rides before this schedule cuts it:
// stop index of its cut yard, or the last stop when uncut (also when the
// cut yard is unknown to this stop list — conservative full reach).
const reachIdxOf = (wagonId: string): number => {
const cutYardId = cutPlan[wagonId];
const idx = cutYardId ? stops.indexOf(cutYardId) : -1;
return idx >= 0 ? idx : Math.max(1, stops.length - 1);
};
// Free for this slot = no already-assigned span on this wagon overlaps the
// slot's own leg. Disjoint legs (alight before board) share the wagon.
const spanFree = (wagonId: string): boolean =>
@@ -5778,9 +5904,15 @@ export class TrainSchedulingService {
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id) &&
// A wagon cut before the slot's alight stop cannot serve it.
reachIdxOf(w.id) >= span[1] &&
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
)
.sort((a, b) => {
// Tightest sufficient reach first: cut-at-B wagons soak up A→B slots
// so full-route wagons stay free for slots that ride to the end.
const reachDelta = reachIdxOf(a.id) - reachIdxOf(b.id);
if (reachDelta !== 0) return reachDelta;
if (a.sequenceNumber == null || b.sequenceNumber == null) {
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
}
@@ -6036,6 +6168,36 @@ export class TrainSchedulingService {
}
}
/**
* Whole-route auto-fill for the secondary flows (previews, single-booking
* add): units that fit nowhere raise ONE grouped, human-readable error —
* never a clamp onto the last wagon that the placement validator then
* rejects once per container. Conservative on leg-sharing trains (treats a
* wagon's TEU as global), so it can say "full" where the main assign path's
* leg-aware fill would still fit — never the reverse.
*/
private autoFillOrFail(
units: ContainerUnitForPlacement[],
slots: number[],
): ContainerPlacementInput[] {
const { placements, overflow } = autoFillPlacements(units, slots);
if (overflow.length) {
const byRef = new Map<string, number>();
for (const u of overflow) {
const ref = u.bookingReference ?? u.bookingId;
byRef.set(ref, (byRef.get(ref) ?? 0) + 1);
}
const detail = [...byRef.entries()]
.map(([ref, n]) => `${ref}: ${n} container(s) have no wagon space left`)
.join('; ');
throw new BadRequestException({
message: `Booking validation failed: ${detail} — the train's container wagons are full`,
violations: [detail],
});
}
return placements;
}
private async persistTrainSetWagons(
manager: EntityManager,
trainSetId: string,
@@ -6405,6 +6567,7 @@ export class TrainSchedulingService {
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const plan = schedule.plannedWagonYards ?? {};
const cutPlan = schedule.plannedWagonCutYards ?? {};
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true, currentYard: true },
@@ -6432,6 +6595,7 @@ export class TrainSchedulingService {
const rows = wagons.map((w) => {
const plannedYardId = scheduleYardOf(plan, w);
const cutYardId = cutPlan[w.id] ?? null;
const locked = lockedIds.has(w.id);
return {
id: w.id,
@@ -6444,6 +6608,8 @@ export class TrainSchedulingService {
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
plannedYardId,
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
cutYardId,
cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null,
aligned: plannedYardId === w.currentYardId,
locked,
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
@@ -6455,6 +6621,7 @@ export class TrainSchedulingService {
pickup: pickupYardIds.has(s.yardId),
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
cut: rows.filter((r) => r.cutYardId === s.yardId).length,
}));
return {
scheduleId,
@@ -6467,14 +6634,16 @@ export class TrainSchedulingService {
}
/**
* Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED
* schedules, only the train's own wagons, only pickup stops of the route,
* never a wagon already carrying this schedule's cargo. Physical yards are
* untouched — the train builder owns those.
* Re-plan which yard this departure boards wagons from (`yardId`) and/or
* where it cuts them mid-route (`cutYardId`; null clears — the wagon rides
* to the destination). Only DRAFT/SCHEDULED schedules, only the train's own
* wagons; boarding only at pickup stops, cutting only at drop stops after
* the boarding yard and never before allocated cargo's destination.
* Physical yards are untouched — the train builder owns those.
*/
async updateScheduleWagonYards(
scheduleId: string,
moves: Array<{ wagonId: string; yardId: string }>,
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>,
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -6505,27 +6674,76 @@ export class TrainSchedulingService {
.map((slot) => slot.physicalWagonId as string),
);
const stopIdx = new Map(stops.map((s, i) => [s.yardId, i]));
const dropYardIds = new Set(stops.slice(1).map((s) => s.yardId));
// Furthest stop any allocated cargo rides to, per physical wagon — a cut
// must not strand cargo short of its destination (equal is fine: cargo
// alights there, then the wagon is cut).
const maxCargoDestIdx = new Map<string, number>();
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId) continue;
for (const alloc of slot.allocations ?? []) {
const dest = alloc.booking?.destinationYardId;
const idx = dest != null ? stopIdx.get(dest) : undefined;
if (idx == null) continue;
const prev = maxCargoDestIdx.get(slot.physicalWagonId) ?? -1;
if (idx > prev) maxCargoDestIdx.set(slot.physicalWagonId, idx);
}
}
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
const cutPlan: Record<string, string> = { ...(schedule.plannedWagonCutYards ?? {}) };
for (const move of moves) {
const wagon = wagonById.get(move.wagonId);
if (!wagon) {
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
}
if (!pickupYardIds.has(move.yardId)) {
throw new BadRequestException(
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
);
if (move.yardId !== undefined) {
if (!pickupYardIds.has(move.yardId)) {
throw new BadRequestException(
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
);
}
if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
);
}
plan[wagon.id] = move.yardId;
}
if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
);
if (move.cutYardId === null) {
delete cutPlan[wagon.id];
} else if (move.cutYardId !== undefined) {
if (!dropYardIds.has(move.cutYardId)) {
throw new BadRequestException(
`Yard ${move.cutYardId} is not a drop stop of this schedule's route`,
);
}
cutPlan[wagon.id] = move.cutYardId;
}
// Validate the combined final plan: boarding must precede the cut,
// whichever side this move changed.
const cutYardId = cutPlan[wagon.id];
if (cutYardId !== undefined) {
const boardYardId = scheduleYardOf(plan, wagon);
const boardIdx = boardYardId != null ? stopIdx.get(boardYardId) ?? 0 : 0;
const cutIdx = stopIdx.get(cutYardId) as number;
if (cutIdx <= boardIdx) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber}: cut yard must come after its boarding yard on the route`,
);
}
const cargoIdx = maxCargoDestIdx.get(wagon.id);
if (cargoIdx != null && cutIdx < cargoIdx) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} carries cargo booked to ${stops[cargoIdx].label} and cannot be cut earlier`,
);
}
}
plan[wagon.id] = move.yardId;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { plannedWagonYards: plan });
.update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan });
// ponytail: per-stop over-booking check counts bookings boarding at the
// stop against wagons planned there, ignoring leg sharing — a warning, not
@@ -7953,6 +8171,8 @@ export class TrainSchedulingService {
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
// Wagons staff plan to cut mid-route are gone from every edge past the cut.
subtractCutWagons(budget, schedule.plannedWagonCutYards);
for (const sb of schedule.scheduleBookings ?? []) {
if (!sb.booking) continue;
budget.subtract(
@@ -8280,8 +8500,14 @@ export class TrainSchedulingService {
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
milestoneYards = milestones.map((m) => m.yardId);
}
// Oriented to THIS schedule's endpoints: a route traversed backwards
// (return-leg reuse) must not silently no-op every cut/leg lookup.
const raw = milestoneYards.length >= 2
? milestoneYards
? orientStopsToSchedule(
milestoneYards,
schedule.originStationId,
schedule.destinationStationId,
)
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
const unique: string[] = [];
for (const yardId of raw) {
@@ -8973,27 +9199,44 @@ export class TrainSchedulingService {
const milestones = [...(schedule.route?.milestones ?? [])].sort(
(a, b) => a.sequenceNo - b.sequenceNo,
);
const raw = milestones.length >= 2
? milestones.map((m) => ({
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
}))
: [
{
yardId: schedule.originStationId,
label:
schedule.originStation?.label ??
schedule.originStation?.code ??
schedule.originStationId,
},
{
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
schedule.destinationStationId,
},
];
const milestoneStops = milestones.map((m) => ({
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
}));
// Same stop shape and orientation as stopYardsForSchedule / stopYardsFor —
// the three builders MUST agree, or validation rejects cuts that capacity
// would honour. Short routes keep a stray milestone as a middle stop; a
// backwards-traversed route is oriented to this schedule's endpoints.
let raw: Array<{ yardId: string; label: string }>;
if (milestoneStops.length >= 2) {
const oriented = orientStopsToSchedule(
milestoneStops.map((s) => s.yardId),
schedule.originStationId,
schedule.destinationStationId,
);
raw =
oriented[0] === milestoneStops[0]?.yardId
? milestoneStops
: [...milestoneStops].reverse();
} else {
raw = [
{
yardId: schedule.originStationId,
label:
schedule.originStation?.label ??
schedule.originStation?.code ??
schedule.originStationId,
},
...milestoneStops,
{
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
schedule.destinationStationId,
},
];
}
const seen = new Set<string>();
return raw.filter((stop) => {
if (!stop.yardId || seen.has(stop.yardId)) return false;
@@ -9090,7 +9333,7 @@ export class TrainSchedulingService {
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const placements = this.autoFillOrFail(units, slots);
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === bookingId,
);
@@ -9222,7 +9465,7 @@ export class TrainSchedulingService {
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const placements = this.autoFillOrFail(units, slots);
const missingForGov = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === governmentBookingId,
);
@@ -9360,7 +9603,7 @@ export class TrainSchedulingService {
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const placements = this.autoFillOrFail(units, slots);
const missingNumbers = findMissingContainerNumberIssues(units, placements);
const missingByBooking = new Map<string, string>();
for (const m of missingNumbers) {
@@ -10062,7 +10305,20 @@ export class TrainSchedulingService {
if (containerBookings.some((b) => b.id === booking.id)) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
// Availability probe — never throws. Overflow reads as "cannot assign",
// not a 500 on the board.
const { placements, overflow } = autoFillPlacements(units, slots);
const overflowHere = overflow.filter((u) => u.bookingId === booking.id).length;
if (overflowHere > 0) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: `${overflowHere} container(s) have no wagon space left on this train`,
shortage: null,
};
}
const missing = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === booking.id,
);