fix issue

This commit is contained in:
Marshal
2026-07-16 00:33:31 +00:00
parent 234a74e812
commit 41fe04652f
51 changed files with 1895 additions and 206 deletions

View File

@@ -769,7 +769,54 @@ export class BookingBatchService implements OnModuleInit {
bookings: Booking[],
scheduleId: string,
): Promise<void> {
for (const b of bookings) await this.reserve(b, scheduleId);
// H8: the capacity check (pickExportSchedule → budget.fits) and the
// reservation writes below are not atomic on their own — two concurrent
// export accepts can each see the same train as fitting and both reserve,
// overshooting the train's capacity. Serialize reservations against this
// schedule: take a pessimistic_write lock on the TrainSchedule row
// (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
// combined need from freshly-committed state INSIDE the lock before the
// reserve writes run. A loser (another accept took the space first) gets a
// ConflictException — the staff accept fails and reverts, exactly as an
// up-front full train does. Covered: the fits-vs-reserve overshoot on the
// export FCFS path; the lock is held for the duration of the reserve writes.
await this.dataSource.transaction(async (manager) => {
const locked = await manager.findOne(TrainSchedule, {
where: { id: scheduleId },
lock: { mode: "pessimistic_write" },
});
if (!locked) {
throw new ConflictException(
"Export train is no longer available for reservation",
);
}
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) {
throw new ConflictException(
"Export train is no longer available for reservation",
);
}
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(
bookings[0].originYardId,
bookings[0].destinationYardId,
);
const need =
bookings.length >= 2
? this.combinedNeed(bookings[0], bookings[1], wagonDims)
: this.needFor(bookings[0], wagonDims);
if (!leg || !budget.fits(need, leg)) {
throw new ConflictException(
"Train is full — no export capacity left for this day",
);
}
for (const b of bookings) await this.reserve(b, scheduleId);
});
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);

View File

@@ -1522,22 +1522,48 @@ export class TrainSchedulingService {
manager,
);
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
// Recompute the train-set composition from whatever survives this removal.
// The removed booking's allocations were already deleted above, so any slot
// left with zero allocations was ridden only by this booking — release it
// (frees its reserved wagon slot). Shared slots keep their surviving
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
// fully-empty train was reset).
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
relations: { allocations: true },
});
let recomputedWeightTons = 0;
let recomputedLengthMeters = 0;
let recomputedWagonCount = 0;
for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) {
await manager.getRepository(TrainSetWagon).delete(slot.id);
continue;
}
const slotWeight = slotAllocations.reduce(
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
0,
);
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
if (Number(slot.assignedWeightTons) !== slotWeight) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
}
recomputedWeightTons += slotWeight;
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
recomputedWagonCount += 1;
}
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: roundTons(recomputedWeightTons),
totalLengthMeters: roundTons(recomputedLengthMeters),
wagonCount: recomputedWagonCount,
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
// the current status (an object literal lets TypeORM's contextual typing
// accept the partial without pulling in relation fields).
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
});
});
await this.trainCompositionRemovalLogRepository.create({
@@ -3228,6 +3254,14 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new BadRequestException(
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
);
}
const now = new Date();
@@ -3397,6 +3431,27 @@ export class TrainSchedulingService {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
// Day-match: a booking scheduled for a specific EAT day must board a train
// departing that same day. forceAssign downgrades a mismatch to a warning
// so staff can knowingly move a booking onto an adjacent-day train.
const scheduleDay = eatDay(new Date(dto.scheduleDate));
const dayMismatched = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
b.scheduledDate != null &&
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
);
if (dayMismatched.length) {
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
.map((b) => b.reference ?? b.id)
.join(', ')}`;
if (forceAssign) {
warnings.push(message);
} else {
violations.push(message);
}
}
if (!forceAssign) {
for (const booking of bookings) {
if (this.isHoldActive(booking)) {