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
@@ -27,9 +27,23 @@ to union by hand. Every later fix would then have to be written twice.
## Consequences
The panel path gains a database-level guard it never had: today its only resource check is an
application-level `isFree()` call with no constraint behind it, so two concurrent requests can both
pass it. Unifying on occupancy also lets `busyIntervals` stop reading two sources.
The panel path gains a database-level guard it never had: its only resource check was an
application-level `isFree()` call with no constraint behind it, so two concurrent requests could both
pass it.
Any booking path that omits the resource keeps the doctor key and the doctor lock. Those paths must
be enumerated when this lands, so none of them silently ends up with weaker protection.
Occupancy is tracked in five-minute buckets, which is coarser than a booking time. Two appointments
that merely touch the same bucket collide even with zero real overlap — a booking ending 12:35:04 and
the next starting 12:35:04 shared one bucket and the second was rejected. Every schedule-driven
booking was already aligned to the grid; only "ثبت خارج از برنامه" produced off-grid seconds. So a
resource booking now snaps both ends of its window down to the bucket grid, which makes the guarantee
exact without changing any behaviour that was already correct. Rounding the end *up* was tried first
and is wrong: it pushes every appointment into the next one's opening bucket.
Booking paths that carry no resource keep the doctor key and the doctor lock. As of this change they
are: the admin path (`AdminApiController`), the hold/engine path (`BookingController`, which is
guarded by occupancy instead), and any doctor-only booking on the public and panel paths.
`app:appointment:backfill-resource-occupancy` gives existing resource-backed appointments the
occupancy rows they never had and clears their now-meaningless doctor keys. It reports rather than
resolves genuine conflicts between two old bookings, because choosing which one loses is not a
decision a migration should make.
+16
View File
@@ -275,6 +275,22 @@ Book an appointment slot.
> مسیر پنل (`POST /api/v1/my/appointment`) این رفتار را از قبل داشت؛ این تغییر مسیر
> عمومی را با آن هم‌تراز کرد.
>
> **رزرو منبع از تقویم پزشک مستقل است.** وقتی `resource_uuid` داده شود:
>
> - قفل و بررسی تداخلِ per-doctor اعمال **نمی‌شود** و `active_slot_key` نال می‌ماند.
> پیش از این کلینیکی که چند دستگاه زیر نظر یک پزشک داشت نمی‌توانست دوتایشان را
> هم‌ساعت رزرو کند.
> - تضمینِ یکتایی از `resource_occupancy` می‌آید که ظرفیت (`capacity`) را می‌فهمد؛
> اتاق سه‌نفره سه رزرو هم‌زمان می‌پذیرد و چهارمی `409` می‌گیرد.
> - شعبهٔ نوبت از خودِ منبع برداشته می‌شود، نه از برنامهٔ هفتگی پزشک.
> - **ساعت نوبت به مرز پنج‌دقیقه‌ای گرد می‌شود** (هر دو سر رو به پایین). اسلات‌های
> برنامهٔ هفتگی از قبل ترازند و تغییری نمی‌کنند؛ فقط «ثبت خارج از برنامه (ورود دستی
> ساعت)» جابه‌جا می‌شود. بدون این، دو نوبتِ پشت‌سرهم سطلِ اشغالِ مشترک پیدا می‌کردند و
> دومی `409` می‌گرفت با اینکه یک ثانیه هم روی هم نبودند.
> - لغو نوبت، اشغال منبع را آزاد می‌کند.
>
> نوبت بدون منبع دقیقاً مثل قبل با کلید و قفل پزشک محافظت می‌شود.
>
> **پاسخ:** علاوه بر فیلدهای قبلی، `resource` (`uuid`, `name`, `type`) و `service_option`
> (`uuid`, `name`) برمی‌گردند. نوبت‌های پیش از مدل منبع‌محور هر دو را `null` دارند، پس
> کلاینت باید با `null` کنار بیاید.
@@ -0,0 +1,123 @@
<?php
namespace App\Appointment\Availability\Service;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Resource\Entity\ClinicResource;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
/**
* گرفتن و آزادکردنِ زمانِ یک منبع — تنها جایی که صندلی‌ها ادعا می‌شوند.
*
* پیش از این فقط مسیر رزرو موقت ردیف اشغال می‌ساخت و مسیرهای مستقیم (پنل و سایت)
* روی `appointments.resource_id` تکیه می‌کردند؛ نتیجه‌اش دو منبعِ حقیقت بود که
* `ResourceBookingSlotService::busyIntervals` باید دستی با هم جمعشان می‌کرد، و
* تضمینِ دیتابیسی فقط پشت یکی‌شان بود.
*/
final class ResourceOccupier
{
public function __construct(private readonly EntityManagerInterface $em) {}
/**
* اولین صندلی آزادِ منبع را برای این بازه می‌گیرد.
*
* سطل‌ها با DBAL خام نوشته می‌شوند نه با ORM: برخورد کلید یکتا در `flush()` خودِ
* EntityManager را می‌بندد و تلاش صندلی بعدی هم با «EntityManager is closed»
* می‌شکست. با DBAL، استثنا فقط یک استثناست و حلقه ادامه می‌یابد.
*
* @throws AppException ۴۰۹ وقتی همهٔ صندلی‌ها گرفته‌اند
*/
public function occupy(
ClinicResource $resource,
int $startsAt,
int $endsAt,
string $status = ResourceOccupancy::STATUS_BOOKED,
?int $appointmentId = null,
?int $holdId = null,
?string $segmentName = null,
): ResourceOccupancy {
$buckets = OccupancyBucket::bucketsFor($startsAt, $endsAt);
$connection = $this->em->getConnection();
for ($seat = 0; $seat < $resource->getCapacity(); $seat++) {
$occupancy = new ResourceOccupancy($resource, $startsAt, $endsAt, $status);
$occupancy->setSegmentName($segmentName);
$occupancy->setHoldId($holdId);
$occupancy->setAppointmentId($appointmentId);
$this->em->persist($occupancy);
$this->em->flush();
try {
foreach ($buckets as $bucketAt) {
$connection->insert('resource_occupancy_buckets', [
'resource_id' => $resource->getId(),
'occupancy_id' => $occupancy->getId(),
'bucket_at' => $bucketAt,
'seat' => $seat,
]);
}
return $occupancy;
} catch (UniqueConstraintViolationException) {
// این صندلی همین حالا گرفته شد. سطل‌های نیمه‌نوشته و خودِ ردیف اشغال
// پاک می‌شوند تا صندلی بعدی از صفر شروع کند.
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
$this->em->remove($occupancy);
$this->em->flush();
}
}
throw new AppException(
ErrorCodes::ERR_SLOT_TAKEN,
sprintf('«%s» در این زمان آزاد نیست', $resource->getName()),
409,
'resource_uuid',
);
}
/**
* آزادسازی: وضعیت `released` و پاک کردن سطل‌ها.
*
* خودِ ردیف اشغال می‌ماند چون تاریخچهٔ بهره‌وری است؛ ولی سطل‌ها باید بروند وگرنه
* کلید یکتا آن زمان را برای همیشه قفل نگه می‌دارد.
*
* @param iterable<ResourceOccupancy> $occupancies
*/
public function release(iterable $occupancies): void
{
$connection = $this->em->getConnection();
$released = false;
foreach ($occupancies as $occupancy) {
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
$occupancy->markReleased();
$released = true;
}
if ($released) {
$this->em->flush();
}
}
/**
* آزادکردنِ هرچه این نوبت گرفته بود — هنگام لغو، انقضا یا جابه‌جایی.
*
* @return int تعداد ردیف‌های آزادشده
*/
public function releaseForAppointment(int $appointmentId): int
{
$occupancies = $this->em->getRepository(ResourceOccupancy::class)->findBy([
'appointmentId' => $appointmentId,
'status' => ResourceOccupancy::BLOCKING_STATUSES,
]);
$this->release($occupancies);
return count($occupancies);
}
}
@@ -75,4 +75,34 @@ class OccupancyBucket
return $buckets;
}
/**
* تراز کردن یک بازه با مرزهای سطل — **هر دو سر رو به پایین**.
*
* دقتِ اشغال در حدِ یک سطل است، پس دو نوبتِ پشت‌سرهم که ثانیه‌شان وسط یک سطل
* می‌افتد متعارض دیده می‌شوند حتی وقتی یک ثانیه هم روی هم نیستند: نوبتی که
* ۱۲:۳۵:۰۴ تمام می‌شود چهار ثانیه وارد سطلِ ۱۲:۳۵ می‌شود و نوبت بعدی هم از همان
* سطل شروع می‌کند.
*
* پایان هم رو به پایین گرد می‌شود، نه رو به بالا: با گرد کردن به بالا، پایانِ هر
* نوبت وارد سطلِ شروعِ نوبت بعدی می‌شد و همان تعارض از سمت دیگر برمی‌گشت.
*
* نوبت‌هایی که از برنامهٔ هفتگی می‌آیند از قبل تراز هستند و این تابع رویشان بی‌اثر
* است؛ فقط ساعتِ دستیِ «ثبت خارج از برنامه» را سرِ جایش می‌نشاند.
*
* @return array{0: int, 1: int} بازهٔ ترازشده
*/
public static function alignWindow(int $start, int $end): array
{
$alignedStart = intdiv($start, self::BUCKET_SECONDS) * self::BUCKET_SECONDS;
$alignedEnd = intdiv($end, self::BUCKET_SECONDS) * self::BUCKET_SECONDS;
// نوبتی کوتاه‌تر از یک سطل نباید به صفر جمع شود، وگرنه هیچ سطلی نمی‌گیرد و
// بی‌محافظت می‌ماند.
if ($alignedEnd <= $alignedStart) {
$alignedEnd = $alignedStart + self::BUCKET_SECONDS;
}
return [$alignedStart, $alignedEnd];
}
}
+18 -50
View File
@@ -3,14 +3,13 @@
namespace App\Appointment\Booking\Service;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Availability\Service\ResourceOccupier;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Appointment\Plan\ValueObject\AppointmentPlan;
use App\Auth\Entity\User;
use App\Resource\Entity\ClinicResource;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
/**
@@ -35,6 +34,7 @@ final class HoldService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ResourceOccupier $occupier,
) {}
/**
@@ -108,6 +108,14 @@ final class HoldService
*
* @throws AppException ۴۰۹ وقتی همهٔ صندلی‌ها گرفته‌اند
*/
/**
* یک بازه را برای یک منبع می‌گیرد، با اولین صندلی آزاد.
*
* منطقِ ادعای صندلی در {@see ResourceOccupier} است چون مسیرهای مستقیمِ رزرو هم
* همان را لازم دارند؛ دو نسخه یعنی دو رفتار متفاوت در برابر برخورد کلید یکتا.
*
* @throws AppException ۴۰۹ وقتی همهٔ صندلی‌ها گرفته‌اند
*/
private function reserve(
ClinicResource $resource,
int $start,
@@ -115,42 +123,13 @@ final class HoldService
AppointmentHold $hold,
?string $segmentName,
): ResourceOccupancy {
$buckets = OccupancyBucket::bucketsFor($start, $end);
$connection = $this->em->getConnection();
for ($seat = 0; $seat < $resource->getCapacity(); $seat++) {
$occupancy = new ResourceOccupancy($resource, $start, $end, ResourceOccupancy::STATUS_HOLD);
$occupancy->setSegmentName($segmentName);
$occupancy->setHoldId($hold->getId());
$this->em->persist($occupancy);
$this->em->flush();
try {
foreach ($buckets as $bucketAt) {
$connection->insert('resource_occupancy_buckets', [
'resource_id' => $resource->getId(),
'occupancy_id' => $occupancy->getId(),
'bucket_at' => $bucketAt,
'seat' => $seat,
]);
}
return $occupancy;
} catch (UniqueConstraintViolationException) {
// این صندلی همین حالا گرفته شد. سطل‌های نیمه‌نوشته و خودِ ردیف اشغال
// پاک می‌شوند تا صندلی بعدی از صفر شروع کند.
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
$this->em->remove($occupancy);
$this->em->flush();
}
}
throw new AppException(
ErrorCodes::ERR_SLOT_TAKEN,
sprintf('«%s» در این زمان ظرفیت خالی ندارد', $resource->getName()),
409,
'assignment',
return $this->occupier->occupy(
$resource,
$start,
$end,
ResourceOccupancy::STATUS_HOLD,
holdId: $hold->getId(),
segmentName: $segmentName,
);
}
@@ -164,18 +143,7 @@ final class HoldService
*/
public function release(array $occupancies): void
{
if ($occupancies === []) {
return;
}
$connection = $this->em->getConnection();
foreach ($occupancies as $occupancy) {
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
$occupancy->markReleased();
}
$this->em->flush();
$this->occupier->release($occupancies);
}
/** @return list<ResourceOccupancy> */
@@ -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;
}
}
@@ -49,6 +49,7 @@ class AppointmentController extends BaseController
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -505,6 +506,13 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422);
}
// ساعتِ نوبتِ منبع‌دار با مرز سطل‌های اشغال تراز می‌شود. اسلات‌های برنامهٔ
// هفتگی از قبل ترازند و این بی‌اثر است؛ ساعتِ دستی وگرنه با نوبتِ چسبیدهٔ
// بعدی سطل مشترک پیدا می‌کرد و بی‌دلیل تعارض می‌ساخت.
if ($resource !== null && $slotEnd > $slotStart) {
[$slotStart, $slotEnd] = \App\Appointment\Booking\Entity\OccupancyBucket::alignWindow($slotStart, $slotEnd);
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
@@ -633,6 +641,23 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
}
// بعد از persist چون شناسهٔ نوبت لازم است. اینجاست که واقعاً صندلیِ منبع ادعا
// می‌شود؛ شکستش یعنی همین لحظه پر شد و نوبت باید برگردد.
if ($resource !== null) {
try {
$this->occupier->occupy(
$resource,
$appointment->getSlotStart(),
$appointment->getSlotEnd(),
appointmentId: (int) $appointment->getId(),
);
} catch (\App\Shared\Exception\AppException) {
$this->appointmentRepo->remove($appointment);
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
}
}
return $this->success(['data' => $appointment->toArray()], 201);
}
@@ -1036,6 +1061,12 @@ class AppointmentController extends BaseController
if (in_array($newStatus, self::CANCEL_STATUSES, true)) {
$reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : '';
$this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user);
// بدون آزادسازی، سطل‌های اشغال می‌مانند و آن دستگاه برای همیشه در آن ساعت
// پر به نظر می‌رسد — نوبتی که لغو شده ولی جایش را پس نداده.
if ($appointment->getResource() !== null) {
$this->occupier->releaseForAppointment((int) $appointment->getId());
}
}
if ($newStatus === Appointment::STATUS_COMPLETED) {
@@ -49,6 +49,7 @@ class MyAppointmentsController extends BaseController
private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
) {}
/**
@@ -226,6 +227,13 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::VALIDATION, 'هزینه ویزیت الزامی است', 422, 'visit_price_rials');
}
// ساعتِ نوبتِ منبع‌دار با مرز سطل‌های اشغال تراز می‌شود. اسلات‌های برنامهٔ
// هفتگی از قبل ترازند و این بی‌اثر است؛ ساعتِ دستیِ «ثبت خارج از برنامه»
// وگرنه با نوبتِ چسبیدهٔ بعدی سطل مشترک پیدا می‌کرد.
if ($resource !== null && !$isReserve && $slotEnd > $slotStart) {
[$slotStart, $slotEnd] = \App\Appointment\Booking\Entity\OccupancyBucket::alignWindow($slotStart, $slotEnd);
}
// Identity is keyed on the national code (unique) so the case-file stays
// single per person even when booked under a different mobile.
$patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName);
@@ -277,14 +285,19 @@ class MyAppointmentsController extends BaseController
}
/**
* تداخل روی خودِ منبع جدا سنجیده می‌شود: `bookAtomically` فقط اسلاتِ پزشک
* را قفل می‌کند و دو پزشکِ متفاوت می‌توانند یک دستگاه را هم‌زمان بگیرند.
* پیش‌بررسیِ زودهنگام تا کاربر پیام روشن بگیرد؛ تضمین واقعی پایین‌تر با
* گرفتنِ صندلی در `resource_occupancy` انجام می‌شود، چون این بررسی
* به‌تنهایی در برابر دو درخواست هم‌زمان محافظت نمی‌کند.
*/
if (!$isReserve && !$this->resourceSlots->isFree($resource, $slotStart, $slotEnd)) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
}
$appointment->setResource($resource);
// شعبهٔ نوبتِ منبع‌دار از خودِ منبع می‌آید، نه از برنامهٔ هفتگی پزشک: دستگاه
// در برنامهٔ پزشک اسلاتی ندارد و `resolveSlotLocationId` برایش null می‌داد.
$appointment->setAddressId($resource->getAddress()->getId());
}
// پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
@@ -322,6 +335,24 @@ class MyAppointmentsController extends BaseController
} catch (SlotTakenException) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
}
// بعد از persist چون شناسهٔ نوبت لازم است. شکستِ گرفتن صندلی یعنی منبع
// همین حالا پر شد؛ نوبت باید برگردد وگرنه رزروی می‌ماند که هیچ دستگاهی
// پشتش نیست.
if ($resource !== null) {
try {
$this->occupier->occupy(
$resource,
$slotStart,
$slotEnd,
appointmentId: (int) $appointment->getId(),
);
} catch (\App\Shared\Exception\AppException) {
$this->appointmentRepo->remove($appointment);
return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
}
}
}
return $this->success([
+10 -1
View File
@@ -279,7 +279,13 @@ class Appointment
{
// Reserve-list entries are day-level wishes, not slot bookings — they
// never occupy a slot, so several reserves may share the same day.
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
// نوبتِ منبع‌دار کلید نمی‌گیرد: تضمینش از `resource_occupancy` می‌آید که ظرفیت و
// بافر را می‌فهمد، در حالی که این کلید فقط پزشک را می‌شناسد. نگه‌داشتنِ هر دو
// یعنی کلینیکی که چند دستگاه زیر نظر یک پزشک دارد، در هر ساعت فقط یکی‌شان را
// می‌تواند رزرو کند.
$this->activeSlotKey = !$this->isReserve
&& $this->resource === null
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
@@ -323,6 +329,9 @@ class Appointment
{
$this->resource = $v;
$this->updatedAt = time();
// منبع در کلید اسلات اثر دارد، و نوبت معمولاً اول ساخته و بعد منبعش ست
// می‌شود — بدون این، کلیدِ ساخته‌شده در سازنده باقی می‌ماند.
$this->refreshActiveSlotKey();
return $this;
}
@@ -39,6 +39,20 @@ class AppointmentRepository extends ServiceEntityRepository
$start = $appointment->getSlotStart();
$end = $appointment->getSlotEnd();
/**
* رزروِ منبع از تقویم پزشک مستقل است: دستگاه را اپراتور کار می‌کند و
* پزشک فقط ناظر است. قفل و بررسیِ per-doctor اینجا اعمال نمی‌شود،
* وگرنه کلینیکی که چند دستگاه زیر نظر یک پزشک دارد نمی‌تواند دوتایشان
* را هم‌زمان رزرو کند. یکتاییِ خودِ منبع را
* `uniq_bucket_resource_seat` تضمین می‌کند که ظرفیت و صندلی می‌فهمد.
*/
if ($appointment->getResource() !== null) {
$em->persist($appointment);
$em->flush();
return;
}
// قفلِ per-doctor (SELECT ... FOR UPDATE روی ردیف پزشک): رزروهای
// هم‌زمانِ یک پزشک را سریالایز می‌کند. در حالت نوبت‌دهی سرویسی که
// نوبت‌ها طول متغیر و شروعِ متفاوت دارند، unique-keyِ (doctor,slot_start)
@@ -0,0 +1,235 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Entity\Appointment;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* رزرو منبع از تقویم پزشک مستقل است.
*
* پیش از این `bookAtomically` روی پزشک قفل می‌گرفت و `isSlotTaken` تداخل را فقط روی
* پزشک می‌سنجید؛ کلینیکی که چند دستگاه زیر نظر یک پزشک داشت — که در دیتابیس واقعی
* حالتِ همهٔ محیط‌ها بود — نمی‌توانست دو دستگاهش را هم‌ساعت رزرو کند.
*/
class ResourceBookingIsIndependentOfDoctorTest extends ApiTestCase
{
/** @return array{0: Clinic, 1: Doctor, 2: DoctorAddress} */
private function clinic(): array
{
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
$clinic->setName('کلینیک چنددستگاهی');
$this->em->persist($clinic);
$this->em->flush();
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return [$clinic, $doctor, $address];
}
private function device(Clinic $clinic, DoctorAddress $address, Doctor $supervisor, string $name, int $capacity = 1): ClinicResource
{
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'دستگاه لیزر');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, $name);
$resource->setSupervisor($supervisor);
$resource->setCapacity($capacity);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
/** @return array{0: array, 1: int} */
private function book(ClinicResource $resource, int $start): array
{
return [$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), [
'resource_uuid' => $resource->getUuid(),
'slot_start' => $start,
'slot_end' => $start + 1800,
'for_self' => true,
'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'patient_gender' => 'female',
]), $start];
}
private function futureSlot(): int
{
return time() + 86_400 + random_int(1, 5_000) * 60;
}
/** ✅ همان باگی که در دیتابیس واقعی فعال بود. */
public function testTwoDevicesUnderOneSupervisorCanBeBookedAtTheSameHour(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$first = $this->device($clinic, $address, $doctor, 'لیزر CO2');
$second = $this->device($clinic, $address, $doctor, 'لیزر NdYAG');
$slot = $this->futureSlot();
$this->book($first, $slot);
self::assertSame(201, $this->responseCode());
$res = $this->book($second, $slot)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
/** ❌ همان دستگاه، همان ساعت، دو بار — باید رد شود. */
public function testTheSameDeviceCannotBeDoubleBooked(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر تک');
$slot = $this->futureSlot();
$this->book($device, $slot);
self::assertSame(201, $this->responseCode());
$res = $this->book($device, $slot)[0];
self::assertSame(409, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
/** ⚠️ اتاق سه‌نفره باید سه رزرو بپذیرد و چهارمی را رد کند. */
public function testARoomWithCapacityThreeTakesThreeBookings(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$room = $this->device($clinic, $address, $doctor, 'اتاق سه‌نفره', capacity: 3);
$slot = $this->futureSlot();
for ($i = 0; $i < 3; $i++) {
$res = $this->book($room, $slot)[0];
self::assertSame(201, $this->responseCode(), 'رزرو ' . ($i + 1) . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE));
}
$this->book($room, $slot);
self::assertSame(409, $this->responseCode());
}
/** رزرو منبع‌دار کلید پزشک نمی‌گیرد؛ تضمینش جای دیگری است. */
public function testAResourceBookingCarriesNoDoctorSlotKeyButDoesOccupy(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر کلیددار');
$slot = $this->futureSlot();
$res = $this->book($device, $slot)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$appointment = $this->em->getRepository(Appointment::class)
->findOneBy(['uuid' => $res['data']['data']['uuid']]);
self::assertNotNull($appointment->getResource());
$occupancies = $this->em->getRepository(ResourceOccupancy::class)
->findBy(['appointmentId' => $appointment->getId()]);
self::assertCount(1, $occupancies);
self::assertSame(ResourceOccupancy::STATUS_BOOKED, $occupancies[0]->getStatus());
}
/**
* دو نوبتِ پشت‌سرهم روی یک دستگاه — هیچ هم‌پوشانی واقعی ندارند.
*
* سطل‌های اشغال پنج‌دقیقه‌ای‌اند، پس نوبتی که ۱۲:۳۵:۰۴ تمام می‌شود چهار ثانیه وارد
* سطلِ ۱۲:۳۵ می‌شد و نوبت بعدی که از همان ثانیه شروع می‌کرد سطل مشترک پیدا
* می‌کرد. این دقیقاً همان چیزی بود که در دیتابیس واقعی روی نوبت‌های ۷۶ و ۷۷ افتاد.
*/
public function testBackToBackBookingsOnOneDeviceDoNotCollide(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر پشت‌سرهم');
// عمداً روی مرز سطل ننشسته — همان «ثبت خارج از برنامه (ورود دستی ساعت)».
$first = $this->futureSlot() + 4;
$this->book($device, $first);
self::assertSame(201, $this->responseCode());
$res = $this->book($device, $first + 1800)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
/** ساعتِ دستی روی مرز سطل می‌نشیند تا اشغال دقیق بماند. */
public function testAManualTimeIsAlignedToTheBucketGrid(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر ترازشونده');
$slot = $this->futureSlot() + 4;
$res = $this->book($device, $slot)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$appointment = $this->em->getRepository(Appointment::class)
->findOneBy(['uuid' => $res['data']['data']['uuid']]);
self::assertSame(0, $appointment->getSlotStart() % 300);
self::assertSame(0, $appointment->getSlotEnd() % 300);
}
/** رزرو بدون منبع همچنان با کلید و قفل پزشک محافظت می‌شود. */
public function testBookingWithoutAResourceIsStillGuardedByTheDoctorKey(): void
{
[$clinic, $doctor] = $this->clinic();
$slot = $this->futureSlot();
$payload = static fn (): array => [
'for_self' => true,
'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'patient_gender' => 'female',
];
$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $payload() + [
'doctor_uuid' => $doctor->getUuid(),
'slot_start' => $slot,
'slot_end' => $slot + 1800,
]);
self::assertSame(201, $this->responseCode());
$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $payload() + [
'doctor_uuid' => $doctor->getUuid(),
'slot_start' => $slot,
'slot_end' => $slot + 1800,
]);
self::assertSame(409, $this->responseCode());
}
/** لغو باید صندلی را پس بدهد، وگرنه دستگاه برای همیشه در آن ساعت پر می‌ماند. */
public function testCancellingReleasesTheSeat(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر لغوشونده');
$slot = $this->futureSlot();
$res = $this->book($device, $slot)[0];
$uuid = $res['data']['data']['uuid'];
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
$this->authJson('PATCH', '/api/v1/appointment/' . $uuid . '/status', $clinic->getUser(), [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$this->em->clear();
$occupancies = $this->em->getRepository(ResourceOccupancy::class)
->findBy(['appointmentId' => $appointment->getId()]);
self::assertSame(ResourceOccupancy::STATUS_RELEASED, $occupancies[0]->getStatus());
// و همان ساعت دوباره قابل رزرو است.
$this->book($device, $slot);
self::assertSame(201, $this->responseCode());
}
}