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:
@@ -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 .` را اجرا کن.
|
||||
Reference in New Issue
Block a user