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()); + } +}