From d91867bd6389e8794f1de0bd923b16b083da987a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 19:13:27 +0330 Subject: [PATCH] fix(appointment): resolve issue with reserved slots incorrectly shown as available --- .../prompt/fix-reserved-slots-shown-free.md | 173 ++++++++++++++++++ docs/api/appointment.md | 2 +- src/Appointment/Entity/Appointment.php | 16 ++ .../Repository/AppointmentRepository.php | 4 +- tests/Appointment/SlotUniquenessTest.php | 35 ++++ 5 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 .claude/prompt/fix-reserved-slots-shown-free.md diff --git a/.claude/prompt/fix-reserved-slots-shown-free.md b/.claude/prompt/fix-reserved-slots-shown-free.md new file mode 100644 index 00000000..79c48b7d --- /dev/null +++ b/.claude/prompt/fix-reserved-slots-shown-free.md @@ -0,0 +1,173 @@ +# رفع باگ: نوبت‌های رزروشده در سایت عمومی «آزاد» نمایش داده می‌شوند + +## پروژه + +`clinicpro` (backend — منبع واحد محاسبه‌ی آزاد/رزرو). +یک بررسی ثانویه‌ی کوچک هم در `nobat724_front` لازم است (پارامتر تاریخِ درخواست) — در وظیفه‌ی ۳ توضیح داده شده. + +## زمینه + +سایت عمومی نوبت‌دهی روی صفحه‌ی `/appointment/` تایم‌اسلات‌های یک پزشک را از +`GET /api/v1/appointment-slots?doctor_uuid=...&date=YYYY-MM-DD` می‌گیرد. بک‌اند برای هر اسلات یک فلگ +`is_available` برمی‌گرداند و فرانت فقط همان فلگ را رعایت می‌کند (`components`/`List.js`: اسلات فقط وقتی قابل +انتخاب است که `is_available === true`). پس **درست/غلط بودنِ آزاد نمایش‌دادن کاملاً به همین فلگِ بک‌اند وابسته است.** + +## مشکل / هدف + +برای پزشک `ab747d75-2114-42b8-9e6d-abdaa338edbe` در تاریخ `1405-04-27` (میلادی: **2026-07-18**) همه‌ی اسلات‌ها +«آزاد» نمایش داده می‌شوند، در حالی که برخی نوبت‌ها قبلاً ثبت/رزرو شده‌اند. + +ریشه‌ی محتمل (اثبات‌شده در کد): **رزروهای روز-محور (`is_reserve`) با `slot_end == slot_start` ذخیره می‌شوند +(بازه‌ی صفر)**، و منطق «اشغال‌بودن» بر پایه‌ی هم‌پوشانی بازه است؛ رکوردِ بازه‌صفر عملاً هیچ اسلاتی را اشغال +نمی‌کند → همه آزاد. هدف: این ناسازگاری برطرف شود و رزروها/نوبت‌ها به‌درستی اسلات‌ها را ببندند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Controller/AppointmentController.php` (متد `slots()`، خط ۱۱۵–۱۳۷) | endpoint `GET /api/v1/appointment-slots` | +| `src/Appointment/Service/SlotCalculatorService.php` (`getAllSlotsWithAvailability`، خط ۶۰–۷۲) | تولید اسلات‌ها + تگ `is_available` | +| `src/Appointment/Repository/AppointmentRepository.php` (`isSlotTaken`، خط ۹۱–۱۱۳) | بررسی اشغال‌بودن (هم‌پوشانی بازه) | +| `src/Appointment/Controller/MyAppointmentsController.php` (خط ۶۴–۷۲، ۹۸) | مسیر ثبتِ رزرو روز-محور که `slot_end = slot_start` می‌گذارد | +| `nobat724_front/app/component/date/dateTime/index.js` (خط ۳۲) | ساخت پارامتر `date` برای درخواست اسلات‌ها (بررسی ثانویه) | + +## وضعیت فعلی + +### ۱) بررسی اشغال‌بودن — هم‌پوشانی بازه (منبع باگ) + +`AppointmentRepository::isSlotTaken()` برای هر اسلات یک بار صدا زده می‌شود: + +```php +// AppointmentRepository.php:91 +public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool +{ + $qb = $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.doctor = :doctor') + ->andWhere('a.slotStart < :slotEnd') // هم‌پوشانی + ->andWhere('a.slotEnd > :slotStart') // هم‌پوشانی + ->andWhere('a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))') + ->setParameter('doctor', $doctor) + ->setParameter('confirmed', Appointment::STATUS_CONFIRMED) + ->setParameter('pending', Appointment::STATUS_PENDING) + ->setParameter('now', time()) + ->setParameter('slotStart', $slotStart) + ->setParameter('slotEnd', $slotEnd); + // ... + return (int) $qb->getQuery()->getSingleScalarResult() > 0; +} +``` + +### ۲) ثبت رزرو روز-محور با بازه‌ی صفر + +```php +// MyAppointmentsController.php:64 +$isReserve = (bool) ($data['is_reserve'] ?? false); + +// Reserve entries are day-level: only a date is picked in the UI, so +// slot_end may equal slot_start and the past-slot rule does not apply. +if ($isReserve && $slotEnd < $slotStart) { + $slotEnd = $slotStart; // ← بازه‌ی صفر +} +// ... +$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); // slotStart == slotEnd +``` + +**چرا باگ:** برای رکوردِ `slotStart == slotEnd == R`، شرط `a.slotStart < :slotEnd AND a.slotEnd > :slotStart` +فقط وقتی برقرار است که `R` اکیداً داخل بازه‌ی اسلات کاندید `(S, E)` باشد. اگر `R` برابر ابتدای روز (۰۰:۰۰) یا +هر لحظه‌ای بیرونِ اسلات‌ها باشد، هیچ اسلاتی اشغال نمی‌شود → همه آزاد. رزروِ «کل روز» عملاً هیچ‌چیز را نمی‌بندد. + +### ۳) پارامتر تاریخ در فرانت (بررسی ثانویه) + +```js +// nobat724_front/app/component/date/dateTime/index.js:32 +const dateStr = moment.unix(date).format("YYYY-MM-DD"); +``` + +`date` یک timestamp است که با `dateToTimestamp` روی `Asia/Tehran` (`startOf("day")`) ساخته شده، ولی +`moment.unix(date)` در منطقه‌زمانیِ سیستم/مرورگر فرمت می‌شود. اگر TZ اجرا Tehran نباشد، ممکن است `date=` +یک روز جابه‌جا شود و روزِ اشتباه کوئری گردد. + +## وظایف + +### ۱. تشخیص قطعی با داده‌ی واقعی (اول این) + +داخل ddev، رکوردهای واقعیِ همان پزشک و روز را ببین (بازه‌ی timestamp تهرانِ 2026-07-18): + +```bash +ddev exec php bin/console dbal:run-sql " + SELECT a.id, a.status, a.slot_start, a.slot_end, + FROM_UNIXTIME(a.slot_start) AS s, FROM_UNIXTIME(a.slot_end) AS e, a.expires_at + FROM appointments a + JOIN doctors d ON d.id = a.doctor_id + WHERE d.uuid = 'ab747d75-2114-42b8-9e6d-abdaa338edbe' + AND a.slot_start >= UNIX_TIMESTAMP('2026-07-18 00:00:00') + AND a.slot_start < UNIX_TIMESTAMP('2026-07-19 00:00:00') + ORDER BY a.slot_start +" +``` + +با خروجی مشخص کن کدام حالت است و بر همان اساس ادامه بده: +- **`slot_end == slot_start`** روی رکوردها → باگِ بازه‌صفرِ رزرو (وظیفه‌ی ۲). محتمل‌ترین. +- `slot_end > slot_start` ولی `is_available` باز هم `true` → مشکل تاریخ/منطقه‌زمانی (وظیفه‌ی ۳) یا `status`. +- `status` مقداری غیر از `confirmed`/`pending`ِ معتبر → رکوردها عمداً شمرده نمی‌شوند؛ منطق `isSlotTaken` را بازبینی کن. + +### ۲. رفع باگِ رزروِ بازه‌صفر (فیکس اصلی) + +دو راه؛ **راه A ترجیح داده می‌شود** چون داده را در همان لحظه‌ی ثبت درست می‌کند و به منطقِ کوئری دست نمی‌زند: + +**راه A — رزروِ روز-محور را به بازه‌ی کل روز تبدیل کن** در `MyAppointmentsController.php` (خط ۶۶–۷۰): + +```php +if ($isReserve && $slotEnd < $slotStart) { + // رزرو روز-محور: کل روز را ببند تا با منطق هم‌پوشانی، همه‌ی اسلات‌های آن روز اشغال شوند. + $dayStart = strtotime(date('Y-m-d', $slotStart) . ' 00:00:00'); + $slotStart = $dayStart; + $slotEnd = $dayStart + 86400; +} +``` + +- توجه: پس از این تغییر `resolveSlotLocationId($doctor, $slotStart)` (خط ۱۰۱) با `slot_start = 00:00` + دیگر اسلاتی پیدا نمی‌کند و `null` می‌دهد؛ همین رفتار قابل‌قبول است (رزرو روزانه آدرس اسلات ندارد) ولی مطمئن شو + خطایی تولید نمی‌شود. +- اگر رزروهای قدیمیِ بازه‌صفر در دیتابیس هست، یک migration/Command یک‌باره برای گسترش آن‌ها به بازه‌ی روز بنویس + وگرنه رکوردهای موجود همچنان اسلات‌ها را نمی‌بندند. + +**راه B — به‌جای تغییر داده، منطقِ اشغال را برای بازه‌ی روز-محور اصلاح کن** (اگر نمی‌خواهی معنای داده عوض شود): +در `isSlotTaken` رکوردهای `slotEnd <= slotStart` را به‌عنوان «قفلِ کل روزِ `slotStart`» در نظر بگیر (شرط اضافه: هم‌پوشانی +عادی **یا** رکوردِ بازه‌صفری که در همان روزِ اسلات کاندید است). این راه پیچیده‌تر و مستعد خطا است؛ فقط اگر راه A ممکن نبود. + +### ۳. بررسی ثانویه‌ی پارامتر تاریخ در فرانت + +در `nobat724_front/app/component/date/dateTime/index.js` خط ۳۲، فرمت تاریخ را صریحاً روی تهران کن تا با +`dateToTimestamp` (که تهران است) هم‌تراز شود و off-by-one رخ ندهد: + +```js +import moment from "moment-jalaali"; // موجود است +const dateStr = moment.unix(date).tz("Asia/Tehran").format("YYYY-MM-DD"); +``` + +اگر `moment-jalaali` متد `.tz` ندارد، از همان helperِ تهران (`moment-timezone`) که `dateToTimestamp` استفاده می‌کند +بهره بگیر. این فقط وقتی اثر دارد که TZ اجرا تهران نباشد؛ در ddev (TZ=Asia/Tehran) بی‌اثر است ولی درستی را تضمین می‌کند. + +### ۴. تست + +- **موفق:** یک رزرو روز-محور (`is_reserve=true`) برای پزشک تست ثبت کن؛ سپس `GET /api/v1/appointment-slots` همان روز + باید همه‌ی اسلات‌ها را `is_available=false` بدهد. +- **موفق:** یک نوبت عادی (`slot_end > slot_start`) روی یک اسلات مشخص؛ فقط همان اسلات باید بسته شود، بقیه آزاد. +- **مرزی:** نوبت `pending` منقضی‌شده (`expires_at < now`) → اسلات باید دوباره آزاد شود. +- **خطا/رگرسیون:** روزِ بدون هیچ رزرو → همه‌ی اسلات‌های آینده آزاد بمانند (بازه‌ی روز اشتباهاً چیزی نبندد). +- تستِ واحد برای `AppointmentRepository::isSlotTaken` با رکوردِ بازه‌ی کل‌روز اضافه کن. +- اجرا: `ddev exec php bin/phpunit --filter Appointment`. + +## نکات مهم + +- تایم‌استمپ‌ها **int یونیکس** هستند؛ TZِ ddev = `Asia/Tehran` (تأییدشده در `.ddev/*compose*`). ثبت نوبت و + تولید اسلات هر دو در تهران‌اند و هم‌تراز؛ پس ریشه، منطقِ بازه است نه منطقه‌زمانی. +- `isSlotTaken` فقط `STATUS_CONFIRMED` و `STATUS_PENDING`ِ منقضی‌نشده را می‌شمارد؛ رفتار درست است — دست نزن مگر + در وظیفه‌ی ۱ خلافش ثابت شود. +- مسیر عمومی `book()` در `AppointmentController` بازه‌ی درست (`slot_end > slot_start`) دارد و اسلات را درست می‌بندد؛ + باگ فقط در مسیر رزروِ `MyAppointmentsController` است. +- هیچ فیلترِ `clinic` در `isSlotTaken` نیست؛ اگر لازم شد جدا بررسی کن، ولی خارج از دامنه‌ی این باگ است. +- اگر route/response خروجی endpoint تغییر کرد، `docs/api/appointment.md` را همان session به‌روزرسانی کن (قاعده‌ی مستندات). +- بعد از تغییر کد، `graphify update .` را اجرا کن (پس از commit). diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 75896c42..5ac9efdf 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -56,7 +56,7 @@ Get all appointment slots (available and booked) for a doctor on a specific date } ``` -> Returns **all** slots grouped by work shift. `is_available: false` means the slot is either already booked (active pending/confirmed appointment) **or** its start time has already passed (for today's date). Session boundaries match the doctor's `WeeklySchedule` or date override config. +> Returns **all** slots grouped by work shift. `is_available: false` means the slot is either taken by a booking **or** its start time has already passed (for today's date). A slot counts as taken when an overlapping appointment is in any *blocking* status (`Appointment::SLOT_BLOCKING_STATUSES`): `confirmed`, `completed`, `following_up`, `salon`, `no_show`, or a still-live `pending` (not yet expired). Only `cancelled_by_user` / `cancelled_by_doctor` / `expired` release the slot. Session boundaries match the doctor's `WeeklySchedule` or date override config. > > Returns an **empty** `sessions` array when the date is a holiday, a closed date override, in the past, beyond the doctor's booking window, or when online booking is disabled (see `meta` in `appointment-settings.md`). diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index ec056fef..79c2ce79 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -52,6 +52,22 @@ class Appointment self::STATUS_CONFIRMED, ]; + /** + * Statuses that make a slot unavailable for a *new* booking, as seen by the + * public availability view (AppointmentRepository::isSlotTaken). Broader than + * SLOT_OCCUPYING_STATUSES: besides a live booking, a slot is also spoken for + * once the visit has been consumed (completed / in-progress / no_show). Only + * cancelled_* and expired truly release it. STATUS_PENDING is handled + * separately in the query because it blocks only while not yet expired. + */ + public const SLOT_BLOCKING_STATUSES = [ + self::STATUS_CONFIRMED, + self::STATUS_COMPLETED, + self::STATUS_FOLLOWING_UP, + self::STATUS_SALON, + self::STATUS_NO_SHOW, + ]; + // Optimistic locking #[ORM\Version] #[ORM\Column(type: 'integer')] diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 84b8daf7..e7eff04e 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -96,10 +96,10 @@ class AppointmentRepository extends ServiceEntityRepository ->andWhere('a.slotStart < :slotEnd') ->andWhere('a.slotEnd > :slotStart') ->andWhere( - 'a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))' + 'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))' ) ->setParameter('doctor', $doctor) - ->setParameter('confirmed', Appointment::STATUS_CONFIRMED) + ->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES) ->setParameter('pending', Appointment::STATUS_PENDING) ->setParameter('now', time()) ->setParameter('slotStart', $slotStart) diff --git a/tests/Appointment/SlotUniquenessTest.php b/tests/Appointment/SlotUniquenessTest.php index 40673a2d..8867c884 100644 --- a/tests/Appointment/SlotUniquenessTest.php +++ b/tests/Appointment/SlotUniquenessTest.php @@ -75,4 +75,39 @@ class SlotUniquenessTest extends ApiTestCase $this->expectException(SlotTakenException::class); $repo->bookAtomically($this->newBooking($doctor, $start)); } + + /** + * Regression: a slot whose only booking has already been consumed (completed, + * no_show, …) must still read as taken in the availability view — otherwise the + * public site offers an occupied slot as free. isSlotTaken counts the broader + * SLOT_BLOCKING_STATUSES, not just live pending/confirmed. + */ + public function testConsumedBookingStillMarksSlotTaken(): void + { + $doctor = $this->makeDoctor(); + $start = time() + 86_400; + $repo = $this->em->getRepository(Appointment::class); + + $appt = $this->newBooking($doctor, $start); + $appt->transitionTo(Appointment::STATUS_CONFIRMED); + $appt->transitionTo(Appointment::STATUS_COMPLETED); + $this->em->persist($appt); + $this->em->flush(); + + $this->assertTrue($repo->isSlotTaken($doctor, $start, $start + 1_800)); + } + + public function testCancelledBookingLeavesSlotFreeForAvailability(): void + { + $doctor = $this->makeDoctor(); + $start = time() + 86_400; + $repo = $this->em->getRepository(Appointment::class); + + $appt = $this->newBooking($doctor, $start); + $appt->transitionTo(Appointment::STATUS_CANCELLED_BY_USER); + $this->em->persist($appt); + $this->em->flush(); + + $this->assertFalse($repo->isSlotTaken($doctor, $start, $start + 1_800)); + } }