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 .` را اجرا کن.
|
||||
@@ -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)}` : ''),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user