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 .` را اجرا کن.
@@ -99,7 +99,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
const svcSlotsQ = useQuery<ApiResponse<any>>({
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
@@ -61,7 +61,7 @@ export default function ServiceSlotPicker({
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
+13 -18
View File
@@ -50,6 +50,16 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [
{ to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon },
];
/**
* همان زیرمنوها + «رزرو نوبت». برای نقش‌هایی که مالک برنامهٔ نوبت‌دهی‌اند
* (ادمین/کلینیک/پزشک) تا همهٔ عملیات نوبت زیر یک بخش جمع شود. پزشکِ مهمانِ کلینیک
* از نسخهٔ بدون رزرو (`APPOINTMENTS_CHILDREN`) استفاده می‌کند.
*/
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
...APPOINTMENTS_CHILDREN,
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon },
];
function buildSections(
primaryRole: string | null,
dbUuid: string | null,
@@ -117,12 +127,7 @@ function buildSections(
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN,
},
{
to: "/admin/appointments/reserve",
icon: CalendarDaysIcon,
label: "نوبت‌های رزرو",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/payments",
@@ -232,12 +237,7 @@ function buildSections(
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN,
},
{
to: "/admin/appointments/reserve",
icon: CalendarDaysIcon,
label: "نوبت‌های رزرو",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/patients",
@@ -377,12 +377,7 @@ function buildSections(
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN,
},
{
to: "/admin/appointments/reserve",
icon: CalendarDaysIcon,
label: "نوبت‌های رزرو",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/patients",
@@ -500,6 +500,28 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
const withClinic = (url: string, clinicUuid?: string | null): string =>
clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url;
/**
* وقتی محیط انتخاب‌شده مکانِ نوبت فعالی ندارد. متن به محیط بستگی دارد: پزشک عضو
* کلینیک آدرس مستقل ثبت نمی‌کند، پس در محیط کلینیک نباید «ابتدا آدرس مطب را ثبت
* کنید» ببیند — آدرس از تنظیمات همان کلینیک می‌آید.
*/
function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
const [title, hint] = clinicUuid
? ['این کلینیک هنوز مکان نوبت‌دهی فعال ندارد', 'ابتدا در تنظیمات کلینیک یک آدرس فعال ثبت شود']
: ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد'];
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">{hint}</p>
</div>
</div>
);
}
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
@@ -595,17 +617,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
);
if (addresses.length === 0) return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">ابتدا آدرس مطب را ثبت کنید</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">برنامه کاری نیاز به حداقل یک مکان نوبت دارد</p>
</div>
</div>
);
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
// نمای فقط‌خواندنی برای نماینده: برنامه‌ی هفتگی به‌صورت متن، بدون فرم.
if (readOnly) {
@@ -1047,17 +1059,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
if (addresses.length === 0) return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">ابتدا آدرس مطب را ثبت کنید</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">برنامه کاری نیاز به حداقل یک مکان نوبت دارد</p>
</div>
</div>
);
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
return (
<div className="space-y-3">
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -43,4 +43,43 @@ describe('AppointmentSettingsPage', () => {
expect(await screen.findByText('قیمت ویزیت آزاد')).toBeInTheDocument();
expect(screen.getByText('این بخش فقط برای پزشک در دسترس است.')).toBeInTheDocument();
});
it('پزشک عضو کلینیک بدون مطب شخصی: پیش‌فرض روی کلینیک، بدون پیام «ابتدا آدرس مطب»', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/doctor/doc-1')
return Promise.resolve({ success: true, data: { data: { uuid: 'doc-1', clinics: [{ uuid: 'clinicX', name: 'کلینیک الف' }] } } });
// مطب شخصی مکانی ندارد → پیش‌فرض باید کلینیک شود
if (url === '/api/v1/appointment-settings/available-locations/doc-1')
return Promise.resolve({ success: true, data: { data: [] } });
if (url.includes('available-locations/doc-1?clinic_uuid=clinicX'))
return Promise.resolve({ success: true, data: { data: [{ id: '5', uuid: 'addr5', type: 'clinic', clinic_id: '9', clinic_name: 'کلینیک الف' }] } });
if (url.includes('/weekly-schedule')) return Promise.resolve({ success: true, data: { data: null } });
return Promise.resolve({ success: true, data: {} });
});
renderWithProviders(<AppointmentSettingsPage />, { route: '/admin/appointment-settings' });
expect(await screen.findByText('محیط نوبت‌دهی')).toBeInTheDocument();
await waitFor(() =>
expect(get).toHaveBeenCalledWith(expect.stringContaining('available-locations/doc-1?clinic_uuid=clinicX')),
);
expect(screen.queryByText('ابتدا آدرس مطب را ثبت کنید')).not.toBeInTheDocument();
});
it('پزشک بدون کلینیک: انتخابگر محیط نمایش داده نمی‌شود', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/doctor/doc-1')
return Promise.resolve({ success: true, data: { data: { uuid: 'doc-1', clinics: [] } } });
if (url === '/api/v1/appointment-settings/available-locations/doc-1')
return Promise.resolve({ success: true, data: { data: [] } });
if (url.includes('/weekly-schedule')) return Promise.resolve({ success: true, data: { data: null } });
return Promise.resolve({ success: true, data: {} });
});
renderWithProviders(<AppointmentSettingsPage />, { route: '/admin/appointment-settings' });
await screen.findByText('قیمت ویزیت آزاد');
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/doctor/doc-1'));
expect(screen.queryByText('محیط نوبت‌دهی')).not.toBeInTheDocument();
});
});
+70 -3
View File
@@ -1,18 +1,70 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import SettingsLayout from '../components/layout/SettingsLayout';
import FreeVisitPrice from '../components/FreeVisitPrice';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import SearchableSelect from '../components/ui/SearchableSelect';
const PERSONAL = 'personal';
/**
* مدیریت نوبت دهی — the doctor's appointment settings: visit price and the full
* weekly/overrides/holidays schedule (moved here from the doctor profile). The
* schedule is now managed exclusively from this page.
* weekly/overrides/holidays schedule.
*
* یک پزشک می‌تواند هم مطب شخصی داشته باشد و هم عضو یک/چند کلینیک باشد. هر محیط
* برنامهٔ نوبت‌دهی مستقل خودش را دارد (schedule per-context با clinic_id). پزشک عضو
* کلینیک آدرس مستقل ثبت نمی‌کند و از Location همان کلینیک استفاده می‌کند؛ پس اگر
* بیش از یک محیط داشته باشد، یک انتخابگر محیط نمایش داده می‌شود تا برنامهٔ همان
* محیط را مدیریت کند. پیش‌فرض روی محیطی می‌رود که مکان فعال دارد.
*/
export default function AppointmentSettingsPage() {
const doctorUuid = useAuthStore((s) => s.doctorUuid);
const dbUuid = useAuthStore((s) => s.dbUuid);
const uuid = doctorUuid ?? dbUuid ?? undefined;
// کلینیک‌هایی که پزشک عضوشان است (منبع: پروفایل خود پزشک).
const profileQ = useQuery({
queryKey: ['doctor-clinics', uuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
enabled: !!uuid,
staleTime: 300_000,
});
// پاسخِ doctor دو لایه تو در تو است: success(['data' => [...]]) → data.data.
const clinics: { uuid: string; name: string }[] =
(profileQ.data?.data as any)?.data?.clinics ?? (profileQ.data?.data as any)?.clinics ?? [];
// آیا مطب شخصی مکان فعال دارد؟ برای انتخاب پیش‌فرضِ درست.
const personalLocationsQ = useQuery({
queryKey: ['available-locations', uuid, null],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/available-locations/${uuid}`),
enabled: !!uuid,
});
const personalLocations: AddressData[] =
(personalLocationsQ.data?.data as any)?.data ?? personalLocationsQ.data?.data ?? [];
const options = useMemo(() => [
{ value: PERSONAL, label: 'مطب شخصی' },
...clinics.map((c) => ({ value: c.uuid, label: c.name })),
], [clinics]);
const [picked, setPicked] = useState<string | null>(null);
// پیش‌فرض: مطب شخصی اگر مکان فعال دارد یا کلینیکی نیست؛ وگرنه اولین کلینیک —
// تا پزشکِ عضوِ کلینیکِ بدونِ مطب شخصی پیام «ابتدا آدرس مطب را ثبت کنید» نبیند.
const defaultContext = useMemo(() => {
if (personalLocations.length > 0 || clinics.length === 0) return PERSONAL;
return clinics[0].uuid;
}, [personalLocations.length, clinics]);
const selected = picked ?? defaultContext;
const clinicUuid = selected === PERSONAL ? null : selected;
const ready = !profileQ.isLoading && !personalLocationsQ.isLoading;
return (
<SettingsLayout active="appointment">
<div
@@ -27,8 +79,23 @@ export default function AppointmentSettingsPage() {
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بخش فقط برای پزشک در دسترس است.
</div>
) : !ready ? (
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
) : (
<ScheduleSection doctorUuid={uuid} />
<>
{clinics.length > 0 && (
<div className="mb-4" style={{ maxWidth: 320 }}>
<label id="appt-context-label" className="block text-xs mb-1" style={{ color: 'var(--text-3)' }}>محیط نوبتدهی</label>
<SearchableSelect
options={options}
value={selected}
onChange={(v) => setPicked(v == null ? PERSONAL : String(v))}
ariaLabelledBy="appt-context-label"
/>
</div>
)}
<ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} />
</>
)}
</div>
</SettingsLayout>
+2 -2
View File
@@ -479,7 +479,7 @@ export default function AppointmentsPage() {
// مطب شخصی خوانده می‌شود. محل از booking-locations همان پزشک انتخاب می‌شود.
const adminLocationsQuery = useQuery<ApiResponse<any>>({
queryKey: ['booking-locations', selectedDoctorUuid],
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}`),
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}?management=1`),
enabled: isAdmin && !!selectedDoctorUuid,
});
const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR;
@@ -496,7 +496,7 @@ export default function AppointmentsPage() {
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: slotsQueryKey,
queryFn: () => api.get(
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` +
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
),
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
+5 -1
View File
@@ -32,8 +32,12 @@ security:
realm: "ClinicPro API Documentation"
provider: api_doc_provider
# اسلات‌ها روی firewall با jwt هستند (نه این firewallِ بی‌احراز): همان اندپوینت
# هم برای سایت عمومی (بدون توکن → ناشناس، توسط access_control با PUBLIC_ACCESS
# مجاز) و هم برای پنل مدیریت (توکن معتبر → احراز می‌شود تا management=1 کار کند)
# سرویس می‌دهد. برای همین از الگوی زیر خارج شده‌اند.
public_endpoints:
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-service-slots|api/v1/appointment-booking-services/|api/v1/appointment-booking-locations/|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-booking-services/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
stateless: true
security: false
+15 -3
View File
@@ -8,13 +8,19 @@
Get all appointment slots (available and booked) for a doctor on a specific date.
**Permission:** `PUBLIC`
**Permission:** `PUBLIC` (anonymous), plus an authenticated **management** mode — see `management` below.
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `date` | string | ✅ | Date in `Y-m-d` format (e.g. `2024-06-15`) |
| `clinic_uuid` | string (UUID) | ❌ | Booking context; omitted = doctor's personal office |
| `management` | `1` | ❌ | Management mode — see note |
> **Management mode (`management=1`).** Turning off online booking (`online_booking_enabled=false`) or the advance booking-window limit are **public-site rules only**. When the request carries a **valid JWT** of a user who may manage this doctor/clinic's appointments (admin, the doctor themself, a clinic manager/secretary with the `appointments` permission), passing `management=1` bypasses those two gates so the panel always shows slots. Past dates are still rejected. If the token is missing or the user is not authorized, `management` is ignored and the endpoint behaves as public (fail-safe). Same flag applies to `/appointment-service-slots`, `/appointment-booking-locations/{doctorUuid}`, and `/appointment-settings/month-availability/{doctorUuid}`.
>
> These four routes moved from the `security: false` firewall onto the JWT firewall so a bearer token can be authenticated on them; anonymous callers still reach them via the `PUBLIC_ACCESS` access-control rules.
### Response `200`
```json
@@ -79,6 +85,7 @@ Get all appointment slots (available and booked) for a doctor on a specific date
| `date` | string `Y-m-d` | ✅ | |
| `service_item_uuids[]` | string[] | ✅ | یک یا چند UUID سرویسِ bookable |
| `durations[<service_uuid>]` | int | ❌ | override مدت (دقیقه) برای همان سرویس — فقط در این محاسبه استفاده می‌شود و مقدار پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند. برای نوبت‌دهیِ منشی که مدت را برای یک نوبت تغییر می‌دهد. مقدار ≤ 0 یا غایب ⇒ مدت پیش‌فرض سرویس |
| `management` | `1` | ❌ | حالت مدیریت — با JWTِ مجاز، توگلِ نوبت‌دهی آنلاین و سقف بازهٔ رزرو دور زده می‌شود (رجوع به توضیح `/appointment-slots`) |
### Response `200`
```json
@@ -148,6 +155,8 @@ Which days of a month are bookable — used by the public calendar to grey out u
|-------|------|----------|-------------|
| `year` | integer | ✅ | **Gregorian** year (e.g. `2026`) |
| `month` | integer | ✅ | Gregorian month `1``12` |
| `clinic_uuid` | string (UUID) | ❌ | Booking context; omitted = personal office |
| `management` | `1` | ❌ | حالت مدیریت — با JWTِ مجاز، توگلِ نوبت‌دهی آنلاین دور زده می‌شود (رجوع به `/appointment-slots`) |
> Input is Gregorian. A Jalali (Shamsi) front-end must convert the displayed month to the Gregorian month(s) it spans before calling.
@@ -897,8 +906,11 @@ once per-context schedules existed.
### GET `/api/v1/appointment-booking-locations/{doctorUuid}`
**Permission:** public — whitelisted in `config/packages/security.yaml` (both the
`public_endpoints` firewall pattern and an `access_control` entry).
**Permission:** public (anonymous via the `PUBLIC_ACCESS` `access_control` entry). This route runs on
the JWT firewall, so a valid bearer + `management=1` enables management mode (`next_available_at` /
`available_on_date` ignore the online-booking toggle) — see the note under `/appointment-slots`.
**Query:** `date` (optional, `Y-m-d`), `management` (optional, `1`).
Lists every place the doctor can be booked at. The site should show **all** of them, grouped by
location — picking one and hiding the rest removes real capacity from the doctor.
+1 -1
View File
@@ -936,7 +936,7 @@ class AdminApiController extends BaseController
}
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$appointment->setClinic($bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId);
// نوبتِ ثبت‌شده توسط ادمین پرداخت آنلاین ندارد و منتظر چیزی نیست؛ قطعی است.
@@ -158,8 +158,9 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic);
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement);
return $this->success([
'doctor_uuid' => $doctorUuid,
@@ -168,7 +169,7 @@ class AppointmentController extends BaseController
'sessions' => $sessions,
// خالی‌بودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد.
'empty_reason' => $sessions === []
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic)
? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic, $forManagement)
: null,
]);
}
@@ -234,7 +235,13 @@ class AppointmentController extends BaseController
'total_duration_minutes' => $totalMinutes,
'buffer_minutes' => (int) $meta['buffer_minutes'],
'clinic_uuid' => $clinic?->getUuid(),
'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes, $clinic),
'start_times' => $this->slotCalculator->getServiceStartTimes(
$doctor,
$date,
$totalMinutes,
$clinic,
$this->isManagementContext($request, $doctor, $clinic),
),
]);
}
@@ -306,8 +313,9 @@ class AppointmentController extends BaseController
continue;
}
$meta = $schedule->getMeta();
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
$meta = $schedule->getMeta();
$address = $byId[$hours[0]['location_id']] ?? $addresses[0];
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$locations[] = [
'location_uuid' => $address->getUuid(),
@@ -321,10 +329,10 @@ class AppointmentController extends BaseController
'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE
? $this->bookableServices($doctor, $clinic)
: [],
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic),
'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic, 30, $forManagement),
'available_on_date' => $date === ''
? null
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic) !== [],
: $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic, $forManagement) !== [],
];
}
@@ -352,14 +360,15 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month');
}
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
$clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid'));
$forManagement = $this->isManagementContext($request, $doctor, $clinic);
$daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month)));
$disabled = [];
$enabled = [];
for ($day = 1; $day <= $daysInMonth; $day++) {
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic)) {
if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic, $forManagement)) {
$enabled[] = $date;
} else {
$disabled[] = $date;
@@ -747,6 +756,25 @@ class AppointmentController extends BaseController
return $this->bookingContext->resolve($doctor, $clinicUuid);
}
/**
* آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟
* اندپوینت‌های اسلات عمومی‌اند؛ فقط با management=1 + کاربرِ احرازشده و مجاز،
* توگلِ نوبت‌دهی آنلاین دور زده می‌شود. در غیر این‌صورت مثل رزرو عمومی رفتار می‌شود
* (fail-safe عمومی) تا اسلاتِ خاموش به بازدیدکنندهٔ سایت نشت نکند.
*/
private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool
{
if ($request->query->get('management') !== '1') {
return false;
}
$user = $this->getUser();
if (!$user instanceof User) {
return false;
}
return $this->accessChecker->canManageContext($user, $doctor, $clinic);
}
private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse
{
[$type, $id] = $clinic !== null
@@ -146,7 +146,7 @@ class MyAppointmentsController extends BaseController
// یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد».
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$appointment->setClinic($bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId);
// Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422.
@@ -78,6 +78,67 @@ class AppointmentAccessChecker
return $this->secretaryCan($appointment, $user, $action);
}
/**
* آیا این کاربر می‌تواند در محیطِ (پزشک + کلینیک) نوبت مدیریت/ثبت کند بدون آنکه
* هنوز نوبتی وجود داشته باشد. برای اندپوینت‌های اسلات که عمومی‌اند ولی وقتی از پنل
* (با management=1) صدا زده می‌شوند باید توگلِ نوبت‌دهی آنلاین را دور بزنند.
*
* منطق همان can() است اما روی محیط، نه روی یک Appointment مشخص.
*/
public function canManageContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
if ($doctor->getUser()->getId() === $user->getId()) {
return true;
}
if ($clinic !== null && $this->clinicPermissions->can($user, $clinic, self::RESOURCE, self::ACTION_UPDATE_STATUS)) {
return true;
}
return $this->secretaryCanContext($user, $doctor, $clinic);
}
/**
* منشی در محیطِ فعالِ خودش، اما روی محیط (پزشک/کلینیک) نه یک نوبت مشخص.
* قرینهٔ secretaryCan() است.
*/
private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return false;
}
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
if ($ctxClinic !== null) {
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
}
// محیطِ مطب شخصی: نوبت هم باید در همان مطب شخصی باشد (clinic == null).
if ($clinic !== null) {
return false;
}
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
}
/**
* کلینیکی که این کاربر در آن اجازهٔ دیدن نوبت‌های این پزشک را دارد، یا null.
* برای لیست‌هایی که باید به یک محیط محدود شوند (نه تک‌نوبت).
@@ -32,9 +32,9 @@ class SlotCalculatorService
*
* @return array[] [{start, end, start_time, end_time, location_id}]
*/
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null): array
public function getAvailableSlots(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
if (empty($sessions)) return [];
$flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions));
return $this->filterBookedSlots($doctor, $flat);
@@ -42,11 +42,14 @@ class SlotCalculatorService
/**
* آدرس (location_id) متناظر با اسلاتِ شروع‌شده در تاریخ مشخص. اگر پیدا نشد null.
*
* برای ثبتِ نوبت از پنل ($forManagement=true) نباید خاموش‌بودنِ نوبت‌دهی آنلاین
* باعث گم‌شدنِ location شود؛ وگرنه نوبتِ دستی بدون آدرس ثبت می‌شد.
*/
public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null): ?int
public function resolveSlotLocationId(Doctor $doctor, int $slotStart, ?Clinic $clinic = null, bool $forManagement = false): ?int
{
$date = date('Y-m-d', $slotStart);
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
foreach ($sessions as $session) {
foreach (($session['slots'] ?? []) as $slot) {
if ((int) ($slot['start'] ?? 0) === $slotStart) {
@@ -64,9 +67,9 @@ class SlotCalculatorService
*
* @return array[] [{start_time, end_time, slots: [{start, end, start_time, end_time, location_id, is_available}]}]
*/
public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): array
public function getAllSlotsWithAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$sessions = $this->buildAllSessions($doctor, $date, $clinic);
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement);
$now = time();
return array_map(fn(array $session) => [
'start_time' => $session['start_time'],
@@ -82,9 +85,9 @@ class SlotCalculatorService
* Whether a doctor has at least one slot on the given date.
* Lightweight check for the month-availability endpoint.
*/
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null): bool
public function hasAnyAvailability(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): bool
{
return !empty($this->buildAllSessions($doctor, $date, $clinic));
return !empty($this->buildAllSessions($doctor, $date, $clinic, $forManagement));
}
/**
@@ -99,7 +102,7 @@ class SlotCalculatorService
*
* @return array<array{start:int,end:int,start_time:string,end_time:string,location_id:?int}>
*/
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null): array
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false): array
{
if ($durationMinutes <= 0) return [];
@@ -107,7 +110,7 @@ class SlotCalculatorService
$durSec = $durationMinutes * 60;
$needSec = $durSec + $buffer * 60; // فضای لازم شامل بافر
$sessions = $this->buildAllSessions($doctor, $date, $clinic); // window/holiday/override/booking-window رعایت می‌شود
$sessions = $this->buildAllSessions($doctor, $date, $clinic, $forManagement); // window/holiday/override/booking-window رعایت می‌شود
if (empty($sessions)) return [];
$dayStart = (int) strtotime($date . ' 00:00:00');
@@ -148,9 +151,9 @@ class SlotCalculatorService
* بدون شیفت و خارج‌بودن از بازهٔ نوبت‌دهی چهار چیز متفاوت‌اند و کاربر باید
* بداند کدام‌یک رخ داده تا بداند چه کاری باید بکند.
*/
public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null): ?string
public function explainEmptyDay(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): ?string
{
if ($this->buildAllSessions($doctor, $date, $clinic) !== []) {
if ($this->buildAllSessions($doctor, $date, $clinic, $forManagement) !== []) {
return null;
}
@@ -165,7 +168,7 @@ class SlotCalculatorService
return self::EMPTY_HOLIDAY;
}
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) {
return self::EMPTY_OUTSIDE_WINDOW;
}
@@ -179,7 +182,7 @@ class SlotCalculatorService
* و نوبت‌های اشغال یک‌بار برای کل بازه واکشی می‌شوند و بقیه در حافظه محاسبه
* می‌شود: ۴ کوئری ثابت به‌جای رشدِ خطی با تعداد روز و اسلات.
*/
public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30): ?int
public function findNextAvailableStart(Doctor $doctor, ?Clinic $clinic = null, int $daysAhead = 30, bool $forManagement = false): ?int
{
$schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic);
if ($schedule === null) {
@@ -187,7 +190,7 @@ class SlotCalculatorService
}
$meta = $schedule->getMeta();
if (!($meta['online_booking_enabled'] ?? true)) {
if (!$forManagement && !($meta['online_booking_enabled'] ?? true)) {
return null;
}
@@ -285,14 +288,22 @@ class SlotCalculatorService
/**
* Booking is allowed only when online booking is enabled and the date is
* today..(today + window). Past dates are always rejected.
*
* مدیریت پنل ($forManagement=true): خاموش‌بودنِ نوبت‌دهی آنلاین و سقفِ بازهٔ
* مجاز رزرو (advance window) فقط قواعد رزرو عمومی از سایت‌اند و نباید جلوی
* نمایش/ثبتِ نوبت توسط پزشک/منشی/ادمین را بگیرند. تاریخِ گذشته همچنان رد می‌شود.
*/
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic): bool
private function isWithinBookingWindow(Doctor $doctor, int $dayStart, ?Clinic $clinic, bool $forManagement = false): bool
{
$todayStart = (int) strtotime('today 00:00:00');
if ($dayStart < $todayStart) {
return false;
}
if ($forManagement) {
return true;
}
$meta = $this->getBookingMeta($doctor, $clinic);
if (!($meta['online_booking_enabled'] ?? true)) {
return false;
@@ -316,13 +327,14 @@ class SlotCalculatorService
*
* @return array[] [{start_time: string, end_time: string, slots: array[]}]
*/
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null): array
private function buildAllSessions(Doctor $doctor, string $date, ?Clinic $clinic = null, bool $forManagement = false): array
{
$dayStart = (int) strtotime($date . ' 00:00:00');
$dayEnd = $dayStart + 86400;
// 0. Online booking disabled or date outside the booking window
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic)) {
// (در کانتکست مدیریت این دو نادیده گرفته می‌شوند — رجوع به isWithinBookingWindow)
if (!$this->isWithinBookingWindow($doctor, $dayStart, $clinic, $forManagement)) {
return [];
}
@@ -0,0 +1,123 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Service\SlotCalculatorService;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* خاموش‌بودنِ نوبت‌دهی آنلاین فقط رزرو عمومی از سایت را متوقف می‌کند. کانتکست مدیریت
* (پزشک/منشی/ادمین، $forManagement=true) باید مستقلاً اسلات ببیند و بتواند نوبت ثبت کند.
* تاریخِ گذشته اما در هر دو حالت رد می‌شود.
*/
class OnlineBookingManagementTest extends ApiTestCase
{
/** @return array{0: Doctor, 1: string} */
private function makeDoctorWithBookingDisabled(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر خاموش');
$this->em->persist($doctor);
$date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true,
'start_time' => '10:00',
'end_time' => '12:00',
'duration_per_patient' => 30,
'location_id' => 1,
]]],
]);
$schedule->setMeta(['online_booking_enabled' => false]);
$this->em->persist($schedule);
$this->em->flush();
return [$doctor, $date];
}
public function testPublicSeesNoSlotsWhenBookingDisabled(): void
{
[$doctor, $date] = $this->makeDoctorWithBookingDisabled();
$calc = static::getContainer()->get(SlotCalculatorService::class);
$this->assertSame([], $calc->getAllSlotsWithAvailability($doctor, $date));
$this->assertSame([], $calc->getAvailableSlots($doctor, $date));
$this->assertFalse($calc->hasAnyAvailability($doctor, $date));
$this->assertSame(SlotCalculatorService::EMPTY_OUTSIDE_WINDOW, $calc->explainEmptyDay($doctor, $date));
}
public function testManagementSeesSlotsWhenBookingDisabled(): void
{
[$doctor, $date] = $this->makeDoctorWithBookingDisabled();
$calc = static::getContainer()->get(SlotCalculatorService::class);
$sessions = $calc->getAllSlotsWithAvailability($doctor, $date, null, true);
$this->assertNotSame([], $sessions);
$this->assertNotSame([], $calc->getAvailableSlots($doctor, $date, null, true));
$this->assertTrue($calc->hasAnyAvailability($doctor, $date, null, true));
$this->assertNull($calc->explainEmptyDay($doctor, $date, null, true));
// location هم باید تعیین شود تا نوبتِ دستی بدون آدرس ثبت نشود.
$firstStart = (int) $sessions[0]['slots'][0]['start'];
$this->assertSame(1, $calc->resolveSlotLocationId($doctor, $firstStart, null, true));
$this->assertNull($calc->resolveSlotLocationId($doctor, $firstStart, null, false));
}
public function testPastDateRejectedEvenForManagement(): void
{
[$doctor] = $this->makeDoctorWithBookingDisabled();
$calc = static::getContainer()->get(SlotCalculatorService::class);
$yesterday = date('Y-m-d', strtotime('yesterday'));
$this->assertSame([], $calc->getAllSlotsWithAvailability($doctor, $yesterday, null, true));
}
private function slotsUri(Doctor $doctor, string $date, bool $management): string
{
return sprintf(
'/api/v1/appointment-slots?doctor_uuid=%s&date=%s%s',
$doctor->getUuid(),
$date,
$management ? '&management=1' : '',
);
}
public function testEndpointAnonymousGetsNoSlots(): void
{
[$doctor, $date] = $this->makeDoctorWithBookingDisabled();
$this->client->request('GET', $this->slotsUri($doctor, $date, false));
$body = json_decode($this->client->getResponse()->getContent(), true);
self::assertSame(200, $this->responseCode());
self::assertSame([], $body['data']['sessions']);
self::assertSame(SlotCalculatorService::EMPTY_OUTSIDE_WINDOW, $body['data']['empty_reason']);
}
public function testEndpointOwnerDoctorWithManagementGetsSlots(): void
{
[$doctor, $date] = $this->makeDoctorWithBookingDisabled();
$body = $this->authJson('GET', $this->slotsUri($doctor, $date, true), $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertNotSame([], $body['data']['sessions']);
}
public function testEndpointManagementFlagIgnoredForUnauthorizedUser(): void
{
[$doctor, $date] = $this->makeDoctorWithBookingDisabled();
$stranger = $this->createUser(['ROLE_USER']);
// management=1 بدون دسترسی نباید توگل را دور بزند (fail-safe عمومی).
$body = $this->authJson('GET', $this->slotsUri($doctor, $date, true), $stranger);
self::assertSame(200, $this->responseCode());
self::assertSame([], $body['data']['sessions']);
}
}