mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
fix issue
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user