Files
clinicpro/.claude/prompt/fix-reserved-slots-shown-free.md

12 KiB

رفع باگ: نوبت‌های رزروشده در سایت عمومی «آزاد» نمایش داده می‌شوند

پروژه

clinicpro (backend — منبع واحد محاسبه‌ی آزاد/رزرو). یک بررسی ثانویه‌ی کوچک هم در nobat724_front لازم است (پارامتر تاریخِ درخواست) — در وظیفه‌ی ۳ توضیح داده شده.

زمینه

سایت عمومی نوبت‌دهی روی صفحه‌ی /appointment/<doctor-uuid> تایم‌اسلات‌های یک پزشک را از 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() برای هر اسلات یک بار صدا زده می‌شود:

// 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;
}

۲) ثبت رزرو روز-محور با بازه‌ی صفر

// 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 برابر ابتدای روز (۰۰:۰۰) یا هر لحظه‌ای بیرونِ اسلات‌ها باشد، هیچ اسلاتی اشغال نمی‌شود → همه آزاد. رزروِ «کل روز» عملاً هیچ‌چیز را نمی‌بندد.

۳) پارامتر تاریخ در فرانت (بررسی ثانویه)

// 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):

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 (خط ۶۶–۷۰):

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 رخ ندهد:

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).