Files
clinicpro/.claude/prompt/fix-appointment-management-online-toggle.md
T
hamed ed516c81a8 feat: Enhance appointment management by decoupling online booking toggle for admin context
- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
2026-07-22 16:43:56 +03:30

18 KiB

اصلاح باگ‌ها و بهبود منطق نوبت‌دهی (پنل مدیریت مستقل از نوبت‌دهی آنلاین + Location پزشک عضو کلینیک + منوی رزرو)

پروژه

clinicpro (Backend Symfony + پنل ادمین React). سه بخش مستقل ولی مرتبط.


زمینه کلی

سه باگ در جریان نوبت‌دهی که همه از یک ریشه می‌آیند: منطق «رزرو عمومی بیمار از سایت» با منطق «مدیریت نوبت توسط دکتر/منشی/ادمین در پنل» تفکیک نشده است.

  • اسلات‌ها هم برای سایت عمومی و هم برای پنل ادمین از یک موتور واحد تولید می‌شوند: SlotCalculatorService. هیچ مسیر جدا برای admin وجود ندارد.
  • اندپوینت‌های اسلات (/api/v1/appointment-slots, appointment-service-slots, month-availability) در security.yaml عمومی (PUBLIC_ACCESS) هستند و بدون auth اجرا می‌شوند؛ پنل ادمین هم همان اندپوینت‌ها را صدا می‌زند.

نتیجه: هر شرطی که برای سایت گذاشته شده (مثل online_booking_enabled) به‌اشتباه روی پنل هم اعمال می‌شود.


وظیفه ۱ — پنل مدیریت باید مستقل از online_booking_enabled نوبت را نشان دهد و ثبت کند

مشکل

وقتی «نوبت‌دهی آنلاین» در /admin/settings/appointment-settings خاموش شود، دکتر/منشی در /admin/appointments (و صفحه رزرو) دیگر اسلات نمی‌بینند و نوبت ثبت نمی‌کنند. پیام «خارج از بازهٔ نوبت‌دهی / نوبت‌دهی آنلاین خاموش است» نمایش داده می‌شود.

ریشه — کد فعلی

فایل: src/Appointment/Service/SlotCalculatorService.php

گیت مرکزی در isWithinBookingWindow() (حدود خط ۲۸۹–۳۰۶):

private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic): bool
{
    $todayStart = (int) strtotime('today 00:00:00');
    if ($dayStart < $todayStart) {
        return false;
    }
    $meta = $this->getBookingMeta($doctor, $clinic);
    if (!($meta['online_booking_enabled'] ?? true)) {   // <-- این خط پنل را هم می‌بندد
        return false;
    }
    // ... در ادامه: محدودیت سقف روزهای آیندهٔ مجاز رزرو (advance window)
}

که در buildAllSessions() صدا زده می‌شود (حدود خط ۳۲۴):

if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
    return [];
}

همهٔ متدهای اسلات از buildAllSessions() عبور می‌کنند: getAvailableSlots(), getAllSlotsWithAvailability(), hasAnyAvailability(), getServiceStartTimes(). یک چک دوم هم در findNextAvailableStart() (حدود خط ۱۹۰) هست.

همچنین POST /api/v1/appointment (book()) و explainEmptyDay() مسیرِ «disabled» را به‌صورت EMPTY_OUTSIDE_WINDOW = 'outside_window' گزارش می‌کنند.

راه‌حل — افزودن «کانتکست مدیریت» ($forManagement)

یک پارامتر بولی bool $forManagement = false را از اندپوینت تا موتور اسلات نخ کن. وقتی true باشد، فقط گیت online_booking_enabled و محدودیت سقف روزهای آیندهٔ رزرو (advance window) نادیده گرفته شوند. سایر قواعد (تعطیلی/holiday، روز تعطیلِ شیفت/day_off، override، اسلاتِ گذشتهٔ همان روز که start < now) دست‌نخورده بمانند.

نکته: چک $dayStart < $todayStart (روز کاملاً گذشته) را نگه دار مگر لازم باشد ثبت گذشته؛ در این تسک فقط توگل آنلاین و advance-window را برای مدیریت باز کن. ثبت نوبتِ گذشته خارج از این تسک است.

۱. امضای متدها را گسترش بده (پیش‌فرض false تا سایت عمومی تغییری نکند):

public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false): array
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): bool
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic, bool $forManagement = false): array
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic, bool $forManagement = false): bool

۲. در isWithinBookingWindow() توگل و advance-window را با $forManagement مشروط کن:

$meta = $this->getBookingMeta($doctor, $clinic);
if (!$forManagement && !($meta['online_booking_enabled'] ?? true)) {
    return false;
}
// advance window (سقف روزهای آیندهٔ مجاز) هم فقط وقتی !$forManagement اعمال شود

۳. اندپوینت‌ها (src/Appointment/Controller/AppointmentController.php): وقتی درخواست از پنل مدیریت است، forManagement را پاس بده.

  • روش تشخیص: پارامتر query management=1 به‌علاوهٔ احراز اینکه کاربرِ لاگین‌کرده واقعاً به این پزشک/کلینیک دسترسی مدیریت دارد. صرفِ وجود پارامتر کافی نیست — چون اندپوینت عمومی است، اگر کاربر لاگین نکرده یا دسترسی ندارد، management نادیده گرفته شود و مثل سایت رفتار کند (fail-safe به عمومی).
  • برای احراز دسترسی از منطق موجود استفاده کن؛ API جدید نساز. مرجع موجود: denyDoctorAccess() در AppointmentSettingsController.php:511-522 و AppointmentAccessChecker/SecretaryPermissionChecker. یک helper خصوصی در کنترلر بساز:
private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool
{
    if ($request->query->get('management') !== '1') return false;
    $user = $this->getUser();               // ممکن است null باشد (اندپوینت عمومی)
    if (!$user instanceof User) return false;
    // admin | خودِ دکتر | منشی/مدیر کلینیک با دسترسی appointment
    // از همان چکِ AppointmentAccessChecker / permChecker موجود استفاده کن، تکراری ننویس
}

سپس در slots():

$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement);

همین کار برای serviceSlots() و monthAvailability().

۴. book() (POST /api/v1/appointment, نیازمند auth): وقتی ثبت‌کننده دکتر/منشی/ادمینِ دارای دسترسی است، چک online_booking_enabled را رد کن. اگر book() مستقیم یا غیرمستقیم از getAvailableSlots() برای اعتبارسنجی اسلات استفاده می‌کند، forManagement=true را برای کاربر مدیریتی پاس بده تا نوبت دستی رد نشود. اگر بیمار خودش ثبت می‌کند، رفتار فعلی حفظ شود.

فرانت‌اند

فایل‌های صفحهٔ نوبت‌ها و رزرو در پنل: assets/admin/pages/ReserveAppointmentsPage.tsx و صفحهٔ /admin/appointments. سرویس فراخوانی اسلات را پیدا کن (assets/admin/lib/api.ts + هوک/کوئری اسلات) و در تمام فراخوانی‌های اسلات/سرویس‌اسلات/month-availability از داخل پنل، پارامتر management=1 اضافه کن. چون lib/api.ts توکن JWT را از localStorage['clinicpro-auth'] خودکار ضمیمه می‌کند، بک‌اند کاربر را می‌شناسد.


وظیفه ۲ — انتقال «رزرو نوبت» به زیرمنوی «نوبت‌دهی»

مشکل

/admin/appointments/reserve الان آیتم منوی جداگانه («نوبت‌های رزرو») است، نه زیرمنوی بخش نوبت‌دهی. همهٔ عملیات نوبت باید زیر یک بخش جمع شود.

کد فعلی

فایل: assets/admin/components/layout/Sidebar.tsx

زیرمنوی مشترک نوبت (خط ۴۸–۵۱):

const APPOINTMENTS_CHILDREN: SubItem[] = [
    { to: "/admin/appointments",     label: "نوبت ها",     icon: CalendarDaysIcon },
    { to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon },
];

آیتم reserve الان جدا و تکراری در هر نقش تعریف شده: admin (خط ۱۲۲–۱۲۶)، clinic (خط ۲۳۸)، doctor (خط ۳۸۳).

راه‌حل

آیتم «رزرو نوبت» را به‌عنوان فرزند سوم به APPOINTMENTS_CHILDREN اضافه کن و آیتم‌های مستقلِ reserve را در سه نقش حذف کن:

const APPOINTMENTS_CHILDREN: SubItem[] = [
    { to: "/admin/appointments",         label: "نوبت ها",     icon: CalendarDaysIcon },
    { to: "/admin/appointments/new",     label: "افزودن نوبت", icon: PlusIcon },
    { to: "/admin/appointments/reserve", label: "رزرو نوبت",   icon: /* آیکن مناسب موجود */ },
];
  • برچسب را یکدست کن («رزرو نوبت» یا همان «نوبت‌های رزرو» — یکی را در کل انتخاب کن).
  • مسیر route در assets/admin/App.tsx:176 تغییر نمی‌کند (همان appointments/reserve با RoleRoute roles={['admin','clinic','doctor','secretary']}). فقط منو اصلاح می‌شود.
  • برای نقش دکترِ مهمانِ کلینیک (scope=clinic, خط ۶۱–۸۳) اگر reserve نباید نمایش داده شود، APPOINTMENTS_CHILDREN مشترک را دستکاری نکن؛ برای آن شاخه یک آرایهٔ children جدا بساز که آیتم reserve را ندارد (تا رفتار فعلی‌اش نشکند). گیت can("appointments","view") حفظ شود.

وظیفه ۳ — پزشک عضو کلینیک نباید مجبور به ثبت آدرس مستقل باشد

مشکل

در /admin/appointment-settings برای پزشکی که داخل کلینیک اضافه شده، پیام «ابتدا آدرس مطب را ثبت کنید» و «برنامه کاری نیاز به حداقل یک مکان نوبت دارد» نمایش داده می‌شود. این پزشک باید از مکان‌های کلینیک استفاده کند.

ریشه — کد فعلی

این دو پیام فرانت‌اند هستند، نه بک‌اند:

فایل: assets/admin/components/schedule/ScheduleSection.tsx

// خط ۵۹۸
if (addresses.length === 0) return ( /* «ابتدا آدرس مطب را ثبت کنید» + «برنامه کاری نیاز به حداقل یک مکان نوبت دارد» */ );

(خطوط ۶۰۴–۶۰۵ متن، و تکرار در ۱۰۵۶–۱۰۵۷ برای تب date-override)

addresses از کوئری خط ۱۳۱۴–۱۳۲۰ می‌آید که اندپوینت زیر را صدا می‌زند:

GET /api/v1/appointment-settings/available-locations/{doctorUuid} (با ?clinic_uuid= اختیاری)

کنترلر: AppointmentSettingsController::availableLocations() (src/Appointment/Controller/AppointmentSettingsController.php:478-498) که از DoctorAddressRepository::findForContext($doctor, $clinic?->getId()) استفاده می‌کند.

منطق resolver — src/Doctor/Repository/DoctorAddressRepository.php:71-88:

// clinicId === null  → فقط آدرس‌های personal همان دکتر (type=personal)
// clinicId set       → فقط آدرس‌های همان کلینیک (type=clinic)
// این دو ست هیچ‌وقت union نمی‌شوند

تشخیص عضویت کلینیک

  • عضویت = جوین ManyToMany clinic_doctors؛ تست: Clinic::hasDoctor($doctor) (src/Clinic/Entity/Clinic.php:157).
  • آدرس‌ها: DoctorAddress با type = personal (متعلق به doctor_id) یا clinic (متعلق به clinic_id) — src/Doctor/Entity/DoctorAddress.php:17-18.

راه‌حل

منطق درست انتخاب مکان (باید رعایت شود):

  1. اگر پزشک مطب مستقل دارد (کانتکست شخصی، clinic_uuid نداریم و آدرس personal دارد) → از آدرس‌های personal استفاده شود.
  2. اگر پزشک عضو کلینیک است و در کانتکست کلینیک تنظیم می‌کند → از آدرس‌های فعال همان کلینیک استفاده شود.
  3. خطای «ابتدا آدرس مطب را ثبت کنید» فقط وقتی نمایش داده شود که: پزشک نه مکان مستقل دارد و نه عضو کلینیکی با مکان فعال است.

بک‌اند: بررسی کن findForContext در کانتکست کلینیک واقعاً آدرس‌های کلینیک را برمی‌گرداند (باید). اگر برای پزشک عضو کلینیک، UI بدون انتخاب کلینیک باز می‌شود و کانتکست شخصی خالی است، مشکل در انتخاب کانتکست پیش‌فرض فرانت است، نه دیتابیس.

فرانت‌اند — گره اصلی: ScheduleSection.tsx باید کانتکست درست را انتخاب کند:

  • اگر پزشک عضو یک/چند کلینیک است، لیست کانتکست‌ها (مطب شخصی + هر کلینیک عضو) را از دادهٔ پروفایل پزشک بگیر و کانتکست فعال را با clinic_uuid صحیح به کوئری available-locations بده.
  • پیام خالی‌بودن را فقط زمانی نشان بده که در کانتکست انتخاب‌شدهٔ فعلی هیچ آدرسی نباشد — و متن را به کانتکست وابسته کن:
    • کانتکست شخصی خالی → «ابتدا آدرس مطب را ثبت کنید».
    • کانتکست کلینیک خالی → پیام مناسب («این کلینیک هنوز مکان نوبت‌دهی فعال ندارد») به‌جای الزام پزشک به ثبت آدرس شخصی.
  • اگر پزشک هیچ مطب شخصی ندارد ولی عضو کلینیک با مکان فعال است، کانتکست پیش‌فرض روی همان کلینیک برود تا پیام اشتباه ظاهر نشود.

منبع تشخیص کانتکست‌های پزشک را در دادهٔ موجود پروفایل/پزشک پیدا کن (کلینیک‌های عضو). اگر اندپوینتی که کلینیک‌های عضو پزشک را می‌دهد وجود ندارد، اول بگرد؛ فقط اگر نبود، توسعه بده (طبق قاعدهٔ ۲ پروژه).


نکات مهم (رعایت الزامی)

  • API جدید فقط در نهایت. اول اندپوینت/منطق موجود را بگرد و توسعه بده (قاعدهٔ ۲ clinicpro/CLAUDE.md). برای احراز دسترسی مدیریت از AppointmentAccessChecker / denyDoctorAccess موجود استفاده کن.
  • Fail-safe عمومی: پارامتر management=1 بدون کاربرِ احرازشده و دارای دسترسی، هرگز نباید توگل را دور بزند — وگرنه یک نشت امنیتی است که اسلات‌های خاموش را به عموم نشان می‌دهد.
  • سایت عمومی nobat724_front همین اندپوینت‌های اسلات را مصرف می‌کند. چون پارامترها پیش‌فرض false/غیرمدیریتی‌اند، رفتار سایت نباید تغییر کند. این را تست کن.
  • تمام رشته‌های UI فارسی، تاریخ‌ها Jalali، RTL. از کامپوننت‌ها و توکن‌های موجود استفاده کن؛ طراحی جدید نساز ([new-pages-follow-existing-design]).
  • SearchableSelect برای انتخاب کانتکست/کلینیک؛ هرگز <select> بومی.
  • تست (قاعدهٔ ۴): برای هر تغییر تست موفق + خطا + مرزی بنویس و اجرا کن:
    • توگل خاموش + کاربر مدیریتی → اسلات دیده می‌شود و نوبت ثبت می‌شود.
    • توگل خاموش + کاربر عمومی/بدون auth → اسلات خالی، empty_reason.
    • management=1 با کاربری که دسترسی ندارد → مثل عمومی رفتار کند.
    • پزشک عضو کلینیک بدون آدرس شخصی → پیام اشتباه ظاهر نشود، مکان‌های کلینیک بیاید.
  • مستندات (Standing Rule): بعد از تغییر اندپوینت‌های اسلات و month-availability، clinicpro/docs/api/appointment.md (و در صورت لزوم فایل مربوط به settings/location) را همان session به‌روز کن — پارامتر جدید management را مستند کن.
  • بعد از تغییر: ddev exec php bin/phpunit، ddev exec php vendor/bin/phpstan analyse، npx tsc --noEmit. اگر Entity تغییر نکرد migration لازم نیست (این تسک احتمالاً بدون تغییر Entity است).
  • بعد از اتمام و کامیت، graphify update . را اجرا کن.