fix(appointment): make resource bookings independent of the doctor's calendar

Booking a device is not booking its doctor: the operator runs it and the doctor
only supervises. But bookAtomically locked the doctor row and isSlotTaken checked
overlap against the doctor alone, ignoring which resource was chosen, so a clinic
whose devices share one supervisor could not run two of them at once. Every
tenant in the database is in that position — clinic 2's six resources all point
at doctor 6.

Resource bookings now skip the doctor lock and carry no active_slot_key; their
guarantee comes from resource_occupancy, which understands capacity and seats.
Both direct paths write occupancy rows the way the hold engine already did, so
ResourceBookingSlotService stops being the only thing holding two sources of
truth together, and cancelling releases the seat.

Occupancy is bucketed in five-minute slices, which is coarser than a booking
time: a booking ending 12:35:04 spilled four seconds into the 12:35 bucket and
collided with the next one starting at that same second, despite zero real
overlap. This surfaced on real rows 76 and 77 during backfill. Resource bookings
now snap both ends of their window down to the bucket grid — schedule-driven
slots are already aligned, so only manually entered times move.

The seat is claimed after persist because it needs the appointment id; losing
the race removes the appointment rather than leaving a booking with no device
behind it.

app:appointment:backfill-resource-occupancy gives existing resource-backed
appointments their missing occupancy and clears the doctor keys that no longer
mean anything. It reports conflicts between two old bookings instead of picking
a loser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 17:57:00 +03:30
co-authored by Claude Opus 5
parent 12c1d2cbf4
commit 9a95bc59d4
11 changed files with 660 additions and 60 deletions
@@ -0,0 +1,129 @@
<?php
namespace App\Appointment\Command;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Availability\Service\ResourceOccupier;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Appointment\Entity\Appointment;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* ردیف اشغال برای نوبت‌های منبع‌دارِ قدیمی.
*
* تا پیش از این، مسیرهای مستقیمِ رزرو فقط `appointments.resource_id` را می‌نوشتند و
* ردیف اشغال نمی‌ساختند؛ حالا که تضمینِ یکتاییِ منبع روی `resource_occupancy` است،
* آن نوبت‌ها بدون این backfill نامرئی می‌مانند و همان ساعت دوباره فروخته می‌شود.
*
* قابل اجرای چندباره: نوبتی که از قبل ردیف دارد رد می‌شود.
*/
#[AsCommand(
name: 'app:appointment:backfill-resource-occupancy',
description: 'Create occupancy rows for live resource-backed appointments that predate them',
)]
final class BackfillResourceOccupancyCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ResourceOccupier $occupier,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
/** @var Appointment[] $appointments */
$appointments = $this->em->createQuery('
SELECT a FROM App\Appointment\Entity\Appointment a
WHERE a.resource IS NOT NULL
AND a.isReserve = false
AND a.status IN (:live)
ORDER BY a.id ASC
')->setParameter('live', [
Appointment::STATUS_PENDING,
Appointment::STATUS_CONFIRMED,
Appointment::STATUS_FOLLOWING_UP,
Appointment::STATUS_SALON,
])->getResult();
$created = 0;
$skipped = 0;
$failed = 0;
foreach ($appointments as $appointment) {
$existing = $this->em->getRepository(ResourceOccupancy::class)->count([
'appointmentId' => $appointment->getId(),
'status' => ResourceOccupancy::BLOCKING_STATUSES,
]);
if ($existing > 0) {
$skipped++;
continue;
}
if ($dryRun) {
$io->text(sprintf('نوبت %d → منبع %s', $appointment->getId(), $appointment->getResource()->getName()));
$created++;
continue;
}
// نوبت‌های قدیمیِ «ورود دستی ساعت» تراز نیستند و بدون این، دو نوبتِ
// پشت‌سرهم سطل مشترک پیدا می‌کنند و دومی جا نمی‌شود.
[$start, $end] = OccupancyBucket::alignWindow(
$appointment->getSlotStart(),
$appointment->getSlotEnd(),
);
try {
$this->occupier->occupy(
$appointment->getResource(),
$start,
$end,
appointmentId: (int) $appointment->getId(),
);
$created++;
} catch (AppException) {
// دو نوبتِ قدیمی روی یک دستگاه و یک ساعت — تعارضی که تا امروز دیده
// نمی‌شد. گزارش می‌شود تا آدمی تصمیم بگیرد، نه اینکه یکی بی‌صدا بپرد.
$io->warning(sprintf(
'نوبت %d روی «%s» جا نشد؛ احتمالاً با نوبت دیگری تداخل دارد',
$appointment->getId(),
$appointment->getResource()->getName(),
));
$failed++;
}
}
// کلید پزشک روی نوبت منبع‌دار دیگر معنا ندارد و جای رزروهای بی‌منبعِ همان پزشک
// را اشغال نگه می‌دارد.
$clearedKeys = $dryRun ? 0 : $this->em->getConnection()->executeStatement(
'UPDATE appointments SET active_slot_key = NULL WHERE resource_id IS NOT NULL AND active_slot_key IS NOT NULL',
);
$io->success(sprintf(
'%d ردیف اشغال ساخته شد، %d از قبل داشت، %d ناموفق، %d کلید اسلات پاک شد%s',
$created,
$skipped,
$failed,
$clearedKeys,
$dryRun ? ' (dry-run)' : '',
));
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
}
}