# جداسازی Context کلینیک و محیط شخصی پزشک در تنظیمات نوبت‌دهی ## پروژه `clinicpro` (Backend Symfony + پنل ادمین React) ## زمینه در مسیر `admin/doctors/{doctorUuid}` (پنل کلینیک) هنگام ذخیرهٔ تنظیمات نوبت‌دهی برای پزشک عضو کلینیک (دکتر تست، موبایل `09100652121`، کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`) با انتخاب حالت «نوبت‌دهی سرویسی» خطای زیر برمی‌گردد: > برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است در حالی که کلینیک سرویس‌های bookable دارد. علت: شمارش سرویس‌ها همیشه با `entity_type='doctor'` انجام می‌شود و هیچ‌وقت سرویس‌های کلینیک را نمی‌بیند. اما این فقط علامتِ یک مشکل معماری بزرگ‌تر است: **کل مدل تنظیمات نوبت‌دهی، context ندارد.** `WeeklySchedule` یک رابطهٔ `OneToOne` با `doctor` دارد و یک unique constraint روی `doctor_id`؛ یعنی یک پزشک که هم مطب شخصی دارد و هم عضو یک یا چند کلینیک است، فقط **یک** برنامهٔ نوبت‌دهی در کل سیستم دارد. سرویس‌ها ولی polymorphic هستند (`service_sections.entity_type` = `doctor|clinic`) و کاملاً از هم جدا. ## مشکل / هدف دو Context باید کاملاً از هم جدا شوند: | Context | مالک تنظیمات | سرویس‌های قابل استفاده | آدرس‌های قابل انتخاب | |---|---|---|---| | محیط شخصی پزشک | `doctor` | فقط `entity_type='doctor', entity_id=doctor.id` | فقط `DoctorAddress` با `type=personal` (یا `clinic_id IS NULL`) | | محیط مدیریت کلینیک | `(doctor, clinic)` | فقط `entity_type='clinic', entity_id=clinic.id` | فقط آدرس‌های همان کلینیک | قوانین: 1. پزشک در محیط شخصی **نباید** به سرویس‌ها، آدرس‌ها یا تنظیمات کلینیک دسترسی داشته باشد. 2. کلینیک در محیط خودش برای پزشک عضو، **باید** بتواند از سرویس‌های کلینیک استفاده کند. 3. یک پزشک باید بتواند برای مطب شخصی و برای هر کلینیک، برنامهٔ نوبت‌دهی مستقل داشته باشد. 4. `booking_mode` (slot/service) در هر context مستقل قفل می‌شود، نه سراسری. ## فایل‌های مرتبط | فایل | نقش | |---|---| | `src/Appointment/Entity/WeeklySchedule.php` | Entity تنظیمات نوبت‌دهی — `OneToOne` با doctor، بدون clinic | | `src/Appointment/Controller/AppointmentSettingsController.php` | همهٔ endpointهای تنظیمات؛ محل خطا و محل authorization | | `src/ClinicService/Repository/ServiceItemRepository.php` | `countBookableByEntity()` / `findBookableByEntity()` | | `src/ClinicService/Entity/ServiceSection.php` | مالکیت polymorphic سرویس (`entityType`/`entityId`) | | `src/ClinicService/Entity/ServiceItem.php` | فلگ `bookable` | | `src/ClinicService/Controller/ClinicServiceController.php` | `resolveEntity()` — تشخیص context از روی role | | `src/Doctor/Entity/DoctorAddress.php` | آدرس با `clinicId` و `type` | | `src/Appointment/Controller/AppointmentController.php:234-262` | لیست عمومی سرویس‌های bookable پزشک | | `src/Auth/Entity/UserActiveContext.php` | context فعال کاربر (فقط `db_uuid`) | | `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحهٔ شخصی پزشک | | `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | صفحهٔ کلینیک، تب به ازای هر پزشک | | `assets/admin/components/schedule/ScheduleSection.tsx` | کامپوننت مشترک هر دو صفحه | | `assets/admin/stores/authStore.ts` | `context: {type: 'doctor'|'clinic'}` | ## وضعیت فعلی ### ۱. شمارش سرویس با `doctor` هاردکد `src/Appointment/Controller/AppointmentSettingsController.php:57-61`: ```php private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool { return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE && $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0; } ``` فراخوانی در `:101-103` (POST) و `:144-146` (PATCH): ```php if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode'); } ``` ### ۲. Entity بدون clinic `src/Appointment/Entity/WeeklySchedule.php:13-49`: ```php #[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] #[ORM\Table(name: 'weekly_schedules')] #[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])] class WeeklySchedule { public const MODE_SLOT = 'slot'; public const MODE_SERVICE = 'service'; ... #[ORM\OneToOne(targetEntity: Doctor::class)] #[ORM\JoinColumn(name: 'doctor_id', onDelete: 'CASCADE')] private Doctor $doctor; #[ORM\Column(type: 'json')] private array $setting = []; ``` ### ۳. تشخیص context فقط از روی role (و doctor برنده است) `src/ClinicService/Controller/ClinicServiceController.php:492-505` — این متد در ۹+ کنترلر تکرار شده: ```php private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { $doctor = $this->doctorRepo->findByUser($user); return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; } if ($user->hasRole('ROLE_CLINIC')) { $clinic = $this->clinicRepo->findByUser($user); return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; } return ['unknown', null]; } ``` کاربری که هر دو role را دارد، همیشه به‌عنوان doctor حل می‌شود و هرگز سرویس‌های کلینیکش را نمی‌بیند. ### ۴. Authorization از کلینیک عبور می‌کند ولی context را حمل نمی‌کند `src/Appointment/Controller/AppointmentSettingsController.php:437-450`: ```php private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse { if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) { return null; } foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) { return null; } } return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } ``` مالک کلینیک مجاز است بنویسد، اما هیچ‌جا مشخص نمی‌شود که این نوشتن «در context کلینیک» است. ### ۵. فرانت context را ارسال نمی‌کند `assets/admin/components/schedule/ScheduleSection.tsx:546-563`: ```tsx ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta }) : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta }); ``` هر دو صفحهٔ شخصی و کلینیک دقیقاً همین `ScheduleSection` را رندر می‌کنند و هیچ تفاوتی در payload ندارند. ## وظایف ### ۱. مدل‌سازی Context در `WeeklySchedule` ستون `clinic_id` (nullable) به `weekly_schedules` اضافه شود: - `clinic_id IS NULL` → context شخصی پزشک - `clinic_id = X` → context کلینیک X برای همین پزشک تغییرات لازم در `src/Appointment/Entity/WeeklySchedule.php`: ```php #[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] #[ORM\Table(name: 'weekly_schedules')] #[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_id'])] class WeeklySchedule { // OneToOne → ManyToOne (یک پزشک چند برنامه دارد: شخصی + هر کلینیک) #[ORM\ManyToOne(targetEntity: Doctor::class)] #[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')] private Doctor $doctor; #[ORM\ManyToOne(targetEntity: Clinic::class)] #[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'CASCADE')] private ?Clinic $clinic = null; ``` نکته دربارهٔ unique در MySQL/MariaDB: `NULL` در unique index تکراری مجاز است، پس `(doctor_id, NULL)` چند بار می‌تواند ثبت شود. برای جلوگیری، یا در سطح Repository قبل از insert چک کن، یا به‌جای NULL از `clinic_id = 0` استفاده کن. **گزینهٔ توصیه‌شده: nullable نگه‌دار و یکتایی را در سرویس/Repository تضمین کن** (سازگارتر با FK). Migration بنویس. برای رکوردهای موجود `clinic_id = NULL` بگذار (همه به‌عنوان تنظیمات شخصی تفسیر می‌شوند) — و در توضیح migration این تصمیم را ذکر کن. #### تصمیم قطعی دربارهٔ `DateOverride` و `Holiday` این دو **معنای متفاوتی** دارند و رفتارشان یکسان نیست: **`DateOverride` → همیشه per-context (`clinic_id` مطابق schedule).** یک override یعنی «ساعت کاری این روزِ خاص با برنامهٔ عادی فرق دارد». ساعت کاری خودش per-context است، پس استثنای آن هم per-context است. اگر پزشک در کلینیک پنجشنبه را تا ۱۲ کار کند، هیچ ربطی به مطب شخصی‌اش ندارد. ستون `clinic_id` nullable اضافه شود و **همیشه با `clinic_id` همان `WeeklySchedule` مقداردهی شود** (NULL = context شخصی). عملاً بهتر است `DateOverride` به `WeeklySchedule` رفرنس بدهد نه به `Doctor`، ولی برای کم‌کردن ریسک migration، `(doctor_id, clinic_id)` کافی است. **`Holiday` → پیش‌فرض سراسری (doctor-level)، با امکان محدودسازی به یک context.** تعطیلی یعنی «پزشک آن روز نیست» — یک واقعیت فیزیکی است. پزشکی که در سفر یا مرخصی است، هم‌زمان در مطب شخصی و در کلینیک غایب است؛ اگر per-context باشد، پزشک باید یک مرخصی را N بار ثبت کند و فراموش‌کردن یکی از آن‌ها = نوبت‌گرفتن بیمار برای روزی که پزشک نیست. این بدترین خطای ممکن در این دامنه است. پس `clinic_id` nullable با این معنا: | `clinic_id` | معنی | |---|---| | `NULL` | پزشک آن روز در **هیچ** محلی نیست — روی همهٔ contextها اثر می‌گذارد | | `X` | پزشک آن روز فقط در کلینیک X نیست (مطب شخصی و بقیه کلینیک‌ها باز) | محاسبهٔ تعطیلی مؤثر برای یک context، **اجتماع** دو مجموعه است: ```php // در HolidayRepository ->where('h.doctor = :doctor') ->andWhere('h.clinic IS NULL OR h.clinic = :clinic') ``` قواعد نوشتن (اجباری، در سرویس اعمال شود): - مالک/کارمند کلینیک فقط می‌تواند `Holiday` با `clinic_id = <کلینیک خودش>` بسازد یا حذف کند. تلاش برای ساخت تعطیلی سراسری (`clinic_id = NULL`) → 403. دلیل: کلینیک نباید بتواند مطب شخصی پزشک را تعطیل کند. - خودِ پزشک در context شخصی می‌تواند هر دو نوع را بسازد، ولی UI باید صریح بپرسد. یک انتخاب دوتایی در فرم ثبت تعطیلی: - «در همهٔ محل‌ها نیستم» → `clinic_id = NULL` (پیش‌فرض) - «فقط در …» → انتخاب یک محل - تعطیلی سراسریِ ساخته‌شده توسط پزشک، در پنل کلینیک **فقط-خواندنی** نمایش داده شود (کلینیک باید ببیند پزشک نیست، ولی نتواند حذفش کند). Migration: همهٔ رکوردهای موجود `Holiday` و `DateOverride` با `clinic_id = NULL` بمانند — برای `Holiday` معنایش دقیقاً همان رفتار فعلی است (سراسری)، برای `DateOverride` یعنی به context شخصی نسبت داده می‌شوند که با تصمیم بند ۱ سازگار است. نکتهٔ مرزی: «کلینیک کلاً تعطیل است» (برای همهٔ پزشکان) با این مدل بیان نمی‌شود و نیاز به یک `ClinicHoliday` جدا دارد. **خارج از scope این تسک** — فقط در `docs/` به‌عنوان کار بعدی ثبت شود. ### ۲. یک سرویس مرکزی برای حل Context به‌جای تکرار `resolveEntity()` در ۹ کنترلر، یک سرویس بساز: `src/Common/Service/EntityContextResolver.php` (یا محل مناسب مطابق ساختار موجود): ```php final class EntityContextResolver { /** * context مؤثر را برمی‌گرداند: ['doctor'|'clinic', id, ?Clinic] * اولویت: clinic_uuid صریح در request > UserActiveContext > role */ public function resolve(User $user, ?string $clinicUuid = null): EntityContext; /** آیا این کاربر مجاز است در context کلینیک داده‌شده عمل کند؟ */ public function assertCanActAs(User $user, EntityContext $ctx): void; } ``` قواعد: - اگر `clinic_uuid` در درخواست آمد → context کلینیک، **مشروط به** اینکه `permChecker->can($user, $clinic, ...)` مجاز باشد؛ در غیر این صورت 403. - اگر نیامد → از `UserActiveContext` بخوان (`src/Auth/Entity/UserActiveContext.php`). - اگر آن هم نبود → fallback به منطق فعلی مبتنی بر role. **مهم:** اولویت فعلی که `ROLE_DOCTOR` را بر `ROLE_CLINIC` مقدم می‌کند، برای کاربر دو-نقشی اشتباه است. با این سرویس، `UserActiveContext` باید تعیین‌کننده باشد. سپس `resolveEntity()` را در کنترلرهای موجود (ClinicService, Inventory, Patient, Staff, Billing, Insurance, Subscription, Tag, Sms) با این سرویس جایگزین کن. اگر ریسک این refactor بزرگ بود، **حداقل `ClinicServiceController` و `AppointmentSettingsController` را مهاجرت بده** و بقیه را در یک TODO مستند کن. ### ۳. اصلاح validation سرویس bookable بر اساس Context در `AppointmentSettingsController`: ```php private function serviceModeHasNoBookable(array $meta, EntityContext $ctx): bool { if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) { return false; } return $this->itemRepo->countBookableByEntity($ctx->type, $ctx->id) === 0; } ``` و پیام خطا بسته به context، دقیق‌تر شود: ```php $msg = $ctx->type === 'clinic' ? 'برای نوبت‌دهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبت‌دهی» داشته باشد' : 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است'; ``` ### ۴. محدودسازی آدرس‌ها بر اساس Context `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` (`:399`) الان همهٔ آدرس‌های شخصی + همهٔ کلینیک‌ها را union می‌کند: ```php $clinics = $this->clinicRepo->findByDoctor($doctor); $clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics); $addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds); ``` باید پارامتر `?clinic_uuid=` بپذیرد: - با `clinic_uuid` → فقط آدرس‌های همان کلینیک - بدون آن (context شخصی) → فقط `DoctorAddress` با `type = TYPE_PERSONAL` / `clinicId IS NULL` همچنین در `validateSessionsHaveLocation()` (`:456-466`) اضافه کن که `location_id` انتخاب‌شده حتماً متعلق به همان context باشد؛ الان هر آدرسی پذیرفته می‌شود. ### ۵. لیست سرویس‌ها برای context الان هیچ endpointای برای «سرویس‌های bookable یک پزشک در یک کلینیک» وجود ندارد؛ `GET /api/v1/service-items` (`ClinicServiceController:217`) owner را از کاربر لاگین‌شده می‌گیرد. - `GET /api/v1/service-items` باید `?clinic_uuid=` بپذیرد و از `EntityContextResolver` استفاده کند. - `AppointmentController.php:234-262` که `findBookableByEntity('doctor', ...)` را هاردکد کرده، باید context را از `WeeklySchedule` مربوطه (که حالا `clinic` دارد) استخراج کند — نه از role. این مسیر عمومی است و `nobat724_front` مصرف‌کنندهٔ آن است. ### ۶. تغییرات endpointهای تنظیمات نوبت‌دهی همهٔ endpointهای `AppointmentSettingsController` باید context بپذیرند: - POST `/api/v1/appointment-settings/weekly-schedule` → بدنه `clinic_uuid` اختیاری - GET/PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}` → query `?clinic_uuid=` - `WeeklyScheduleRepository` متد `findOneByDoctorAndClinic(Doctor $d, ?Clinic $c)` بگیرد؛ همهٔ `findOneBy(['doctor' => ...])`ها به‌روز شوند. - `assertModeImmutable()` باید mode را از schedule همان context بخواند، نه از تنها schedule پزشک. پاسخ‌ها طبق `BaseController` با `$this->success()` / `$this->error()` بمانند. ### ۷. پنل ادمین React - `assets/admin/components/schedule/ScheduleSection.tsx` یک prop جدید `clinicUuid?: string` بگیرد و در هر دو فراخوانی POST/PATCH و در query key و در fetch آدرس‌ها آن را ارسال کند. - `AppointmentSettingsPage.tsx` (شخصی) → `clinicUuid` ندهد. - `ClinicAppointmentSettingsPage.tsx` → `clinicUuid={clinicUuid}` بدهد. - query keyهای React Query حتماً شامل `clinicUuid` شوند، وگرنه cache بین دو context نشت می‌کند. - متن راهنمای `ScheduleSection.tsx:715` بسته به context متفاوت شود: در کلینیک به بخش سرویس‌های کلینیک ارجاع دهد. ### ۸. مستندات و تست - فایل‌های `docs/api/` مربوط به appointment-settings و service-items با پارامتر جدید `clinic_uuid` به‌روز شوند (قانون ثابت پروژه). - تست موجود `tests/Appointment/AppointmentSettingsListOwnershipTest.php` را گسترش بده؛ حداقل این سناریوها: 1. پزشک عضو کلینیک، در context شخصی، mode=service با صفر سرویس شخصی → 422. 2. همان پزشک در context کلینیک که کلینیک سرویس bookable دارد → 200. 3. پزشک در context شخصی نمی‌تواند `location_id` متعلق به کلینیک را انتخاب کند → 422. 4. دو schedule مستقل برای یک پزشک (شخصی + کلینیک) هم‌زمان ذخیره می‌شوند و mode مستقل قفل می‌شود. 5. کاربری بدون permission روی کلینیک، با `clinic_uuid` آن کلینیک → 403. ### ۹. داشبورد پزشک دعوت‌شده در context کلینیک **مشکل مشاهده‌شده:** پزشک دعوت‌شده («دکتر دعوت تست ۲») وقتی داخل محیط کلینیک «علی بهروزی» است، داشبورد کاملِ پزشک را می‌بیند: «میزان درآمد»، «کل پرداختی‌ها»، «پرداختی‌های امروز»، «تعداد کل مراجعین» و کارت «کلینیک‌های من». این داده‌ها به context شخصی پزشک تعلق دارند و نباید در محیط کلینیک نمایش داده شوند. علاوه بر این، پزشک دعوت‌شده اصلاً نباید اطلاعات مالی ببیند. **ریشه:** انتخاب داشبورد فقط بر اساس `primaryRole` است و `context.scope` نادیده گرفته می‌شود. `assets/admin/pages/DashboardPage.tsx:1116`: ```tsx export default function DashboardPage() { const primaryRole = useAuthStore(s => s.primaryRole); if (!primaryRole) return ; if (primaryRole === 'admin') return ; if (primaryRole === 'clinic') return ; if (primaryRole === 'doctor') return ; ... ``` در حالی که Sidebar **دقیقاً همین تمایز را می‌شناسد** — `assets/admin/components/layout/Sidebar.tsx:60-83`: ```tsx if (primaryRole === "doctor" && scope === "clinic") { const items: SectionItem[] = [ { to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }, ]; if (can("appointments", "view")) { ... } if (can("patients", "view")) { ... } return [{ label: "عمومی", items }]; } ``` منبع `scope`: `src/Auth/Controller/AuthController.php:700-729` — پزشک دعوت‌شده `role='doctor'`, `scope='clinic'`, `permissions` از `ClinicDoctorPermission`؛ مالک کلینیک `role='clinic'`, `scope=null`, `permissions=null`. **وظایف:** 1. در `DashboardPage.tsx` قبل از dispatch، `scope` را هم بخوان و یک شاخهٔ جدید اضافه کن: ```tsx const primaryRole = useAuthStore(s => s.primaryRole); const scope = useAuthStore(s => s.context?.scope ?? null); ... if (primaryRole === 'doctor' && scope === 'clinic') return ; if (primaryRole === 'doctor') return ; ``` 2. `InvitedDoctorDashboard` فقط این‌ها را نشان دهد: - «تعداد نوبت‌های امروز» (محدود به نوبت‌های همین پزشک در همین کلینیک) - «لیست نوبت‌های جدید» همین پزشک در همین کلینیک - در صورت داشتن `can('patients','view')`، «تعداد مراجعین» همین context و این‌ها **حذف** شوند: «میزان درآمد»، «کل پرداختی‌ها»، «پرداختی‌های امروز»، «نمودار درآمد»، کارت «کلینیک‌های من» (`DashboardPage.tsx:851`)، و کارت دعوت‌های کلینیک (`DoctorClinicInvitationsCard`, `:709`) — دعوت‌ها فقط در context شخصی معنا دارند. کارت‌ها بر اساس `permissions` همان context نمایش داده شوند (همان `usePermissions()` که Sidebar استفاده می‌کند)، نه صرفاً hardcode. 3. **Backend مهم‌تر است — مخفی‌کردن در UI کافی نیست.** `src/Dashboard/Controller/DashboardController.php:180-182` (`GET /api/v1/dashboard/doctor`) داده را از `doctorRepo->findByUser($user)` می‌گیرد و روی **همهٔ کلینیک‌ها + مطب شخصی** جمع می‌زند؛ `UserActiveContextRepository` تزریق شده (`:34`) ولی مصرف نمی‌شود. پزشک دعوت‌شده الان می‌تواند مستقیماً این endpoint را صدا بزند و درآمد شخصی‌اش را بگیرد. - `?clinic_uuid=` بپذیرد و از `EntityContextResolver` (وظیفهٔ ۲) استفاده کند. - وقتی context کلینیک است: فیلدهای مالی (`revenue_period_rials`, `today_payments_rials`, `charts.revenue_by_day`) در پاسخ **قرار نگیرند** مگر اینکه `permChecker` مجوز مالی (`billing`/`payments` view) برای آن پزشک در آن کلینیک بدهد. - آمار نوبت/بیمار به نوبت‌های همان پزشک در همان کلینیک محدود شود، نه همهٔ کلینیک‌ها. - `sms_balance` هم در context کلینیک نباید از کیف پول شخصی پزشک خوانده شود. 4. مسیر `/admin/dashboard` در `assets/admin/App.tsx:171` هیچ role gate ندارد؛ لازم نیست gate اضافه شود (خود صفحه dispatch می‌کند) اما مطمئن شو `RoleRoute` مسیرهای مالی را برای `scope === 'clinic'` مسدود می‌کند. 5. تست: پزشک دعوت‌شده در context کلینیک، `GET /api/v1/dashboard/doctor?clinic_uuid=...` → پاسخ نباید هیچ فیلد مالی داشته باشد؛ و بدون `clinic_uuid` وقتی active context کلینیک است، نتیجه باید همان محدودیت را داشته باشد. ### ۱۰. قرارداد عمومی برای چند schedule (مصرف‌کننده: `nobat724_front`) **تصمیم قطعی: همهٔ scheduleها نمایش داده شوند، تفکیک‌شده بر اساس محل نوبت‌دهی.** انتخاب یکی و پنهان‌کردن بقیه یعنی حذف ظرفیت واقعی پزشک از سایت — پزشکی که سه‌شنبه‌ها فقط در کلینیک است، آن روز اصلاً قابل رزرو نخواهد بود. ضمناً قیمت و سرویس‌ها بین محل‌ها فرق می‌کند، پس بیمار باید محل را آگاهانه انتخاب کند، نه اینکه سیستم به‌جایش تصمیم بگیرد. قرارداد API عمومی — به‌جای یک آبجکت، آرایه‌ای از «محل‌های نوبت‌دهی» برگردد: ```json { "success": true, "data": { "doctor": { "uuid": "...", "name": "..." }, "booking_locations": [ { "location_uuid": "...", "type": "personal", "title": "مطب شخصی", "address": "...", "clinic_uuid": null, "booking_mode": "slot", "services": [], "next_available_at": 1755000000 }, { "location_uuid": "...", "type": "clinic", "title": "کلینیک علی بهروزی", "address": "...", "clinic_uuid": "41e325c4-...", "booking_mode": "service", "services": [ { "uuid": "...", "name": "...", "price_rials": 0, "duration_minutes": 20 } ], "next_available_at": 1754900000 } ] } } ``` قواعد: - **پیش‌فرض انتخاب‌شده:** محلی با کمترین `next_available_at` (زودترین نوبت آزاد). این هم برای بیمار بهترین است و هم نیاز به قاعدهٔ دلبخواهی «شخصی اول یا کلینیک اول» را حذف می‌کند. اگر هیچ محلی نوبت آزاد نداشت، ترتیب: شخصی، سپس کلینیک‌ها بر اساس نام. - **لینک مستقیم:** `/doctor/{uuid}?location={location_uuid}` تا هر محل قابل اشتراک‌گذاری و ایندکس باشد. بدون پارامتر → پیش‌فرض بالا. - **endpointهای اسلات و ثبت نوبت** باید `location_uuid` (یا `clinic_uuid`) اجباری بگیرند. الان محل را از تنها schedule پزشک استنتاج می‌کنند؛ با چند schedule این استنتاج غلط می‌شود و **بی‌سروصدا نوبت را به محل اشتباه ثبت می‌کند**. این را به‌عنوان یک شکست خاموش جدی در نظر بگیر: تا وقتی این پارامتر اجباری نشده، migration بند ۱ را روی production اجرا نکن. - **سازگاری عقب‌رو:** تا وقتی `nobat724_front` به‌روز نشده، اگر پزشک فقط یک schedule دارد (اکثریت مطلق داده‌های فعلی)، پاسخ قدیمی هم در کنار `booking_locations` برگردانده شود؛ بعد از استقرار فرانت حذف شود. این را در `docs/api/appointment.md` صریح علامت بزن. - **JSON-LD:** به‌جای یک `openingHoursSpecification`، برای هر محل یک entry جدا با `location` مشخص. یک نود `Physician` با چند `availableAtOrFrom`. پرامپت همتا در `nobat724_front` لازم است. ## نکات مهم - **سازگاری با داده موجود:** هر پزشکی که الان schedule دارد، بعد از migration باید دقیقاً همان رفتار را در context شخصی ببیند. اگر آن schedule عملاً برای کلینیک تنظیم شده بوده (session‌هایش `location_id` کلینیکی دارند)، migration نمی‌تواند خودکار تشخیص دهد — این را به‌عنوان محدودیت شناخته‌شده مستند کن و یک اسکریپت console برای انتقال دستی بنویس. - سرویس‌ها polymorphic هستند و **هرگز** بین doctor و clinic مشترک نمی‌شوند؛ هیچ‌جا سرویس‌های دو context را union نکن. - تاریخ‌ها Unix timestamp صحیح بمانند؛ رشته‌های جدید فارسی و تاریخ‌ها شمسی. - در پنل ادمین از `SearchableSelect` استفاده کن، نه `