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.
This commit is contained in:
hamed
2026-07-22 16:43:56 +03:30
parent 5507b42fd8
commit ed516c81a8
16 changed files with 658 additions and 83 deletions
@@ -0,0 +1,232 @@
# اصلاح باگ‌ها و بهبود منطق نوبت‌دهی (پنل مدیریت مستقل از نوبت‌دهی آنلاین + 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()` (حدود خط ۲۸۹–۳۰۶):
```php
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()` صدا زده می‌شود (حدود خط ۳۲۴):
```php
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` تا سایت عمومی تغییری نکند):
```php
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` مشروط کن:
```php
$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 خصوصی در کنترلر بساز:
```php
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()`:
```php
$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`
زیرمنوی مشترک نوبت (خط ۴۸–۵۱):
```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 را در سه نقش حذف کن:
```tsx
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`
```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`:
```php
// 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 .` را اجرا کن.