From 9a95bc59d4c2f04887870e903e6a3f90c6aea3fa Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 6 Aug 2026 17:57:00 +0330 Subject: [PATCH] fix(appointment): make resource bookings independent of the doctor's calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...d-appointments-drop-the-doctor-slot-key.md | 24 +- docs/api/appointment.md | 16 ++ .../Availability/Service/ResourceOccupier.php | 123 +++++++++ .../Booking/Entity/OccupancyBucket.php | 30 +++ .../Booking/Service/HoldService.php | 68 ++--- .../BackfillResourceOccupancyCommand.php | 129 ++++++++++ .../Controller/AppointmentController.php | 31 +++ .../Controller/MyAppointmentsController.php | 35 ++- src/Appointment/Entity/Appointment.php | 15 +- .../Repository/AppointmentRepository.php | 14 ++ ...sourceBookingIsIndependentOfDoctorTest.php | 235 ++++++++++++++++++ 11 files changed, 660 insertions(+), 60 deletions(-) create mode 100644 src/Appointment/Availability/Service/ResourceOccupier.php create mode 100644 src/Appointment/Command/BackfillResourceOccupancyCommand.php create mode 100644 tests/Appointment/ResourceBookingIsIndependentOfDoctorTest.php diff --git a/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md b/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md index 6a9d8cbc..b54b4dda 100644 --- a/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md +++ b/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md @@ -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. diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 87c19e45..eb013aa5 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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` کنار بیاید. diff --git a/src/Appointment/Availability/Service/ResourceOccupier.php b/src/Appointment/Availability/Service/ResourceOccupier.php new file mode 100644 index 00000000..9a22750f --- /dev/null +++ b/src/Appointment/Availability/Service/ResourceOccupier.php @@ -0,0 +1,123 @@ +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 $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); + } +} diff --git a/src/Appointment/Booking/Entity/OccupancyBucket.php b/src/Appointment/Booking/Entity/OccupancyBucket.php index 90d3b47a..214ea81e 100644 --- a/src/Appointment/Booking/Entity/OccupancyBucket.php +++ b/src/Appointment/Booking/Entity/OccupancyBucket.php @@ -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]; + } } diff --git a/src/Appointment/Booking/Service/HoldService.php b/src/Appointment/Booking/Service/HoldService.php index ba4fd193..59b1d041 100644 --- a/src/Appointment/Booking/Service/HoldService.php +++ b/src/Appointment/Booking/Service/HoldService.php @@ -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 */ diff --git a/src/Appointment/Command/BackfillResourceOccupancyCommand.php b/src/Appointment/Command/BackfillResourceOccupancyCommand.php new file mode 100644 index 00000000..0030d551 --- /dev/null +++ b/src/Appointment/Command/BackfillResourceOccupancyCommand.php @@ -0,0 +1,129 @@ +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; + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index e7566cf1..9c09be61 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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) { diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 8196b356..0fede77b 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -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([ diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index a96b0a49..fcfffb6c 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -279,9 +279,15 @@ 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) - ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) - : null; + // نوبتِ منبع‌دار کلید نمی‌گیرد: تضمینش از `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; } public function getId(): ?int { return $this->id; } @@ -323,6 +329,9 @@ class Appointment { $this->resource = $v; $this->updatedAt = time(); + // منبع در کلید اسلات اثر دارد، و نوبت معمولاً اول ساخته و بعد منبعش ست + // می‌شود — بدون این، کلیدِ ساخته‌شده در سازنده باقی می‌ماند. + $this->refreshActiveSlotKey(); return $this; } diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 3cdaed0d..ecc5d90b 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -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) diff --git a/tests/Appointment/ResourceBookingIsIndependentOfDoctorTest.php b/tests/Appointment/ResourceBookingIsIndependentOfDoctorTest.php new file mode 100644 index 00000000..d4c63d74 --- /dev/null +++ b/tests/Appointment/ResourceBookingIsIndependentOfDoctorTest.php @@ -0,0 +1,235 @@ +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()); + } +}