# جداسازی 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<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
        : api.post<ApiResponse<any>>('/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 <LoadingSkeleton />;
  if (primaryRole === 'admin')          return <AdminDashboard />;
  if (primaryRole === 'clinic')         return <ClinicDashboard />;
  if (primaryRole === 'doctor')         return <DoctorDashboard />;
  ...
```

در حالی که 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 <InvitedDoctorDashboard />;
if (primaryRole === 'doctor')                       return <DoctorDashboard />;
```

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` استفاده کن، نه `<select>` بومی.
- **نشت داده مالی:** وظیفهٔ ۹ فقط یک مسئلهٔ UI نیست — `GET /api/v1/dashboard/doctor` الان درآمد شخصی پزشک را بدون هیچ فیلتر contextی برمی‌گرداند. اصلاح backend اجباری است.
- ترتیب پیاده‌سازی پیشنهادی: (۱) Entity + migration → (۲) `EntityContextResolver` → (۳) کنترلر تنظیمات + validation → (۴) آدرس‌ها و سرویس‌ها → (۵) فرانت → (۶) `AppointmentController` عمومی → (۷) داشبورد پزشک دعوت‌شده (وظیفهٔ ۹) → (۸) تست و docs. هر مرحله جدا تست شود. وظیفهٔ ۹ به `EntityContextResolver` وابسته است ولی مستقل از migration قابل شروع است.
- کاربر تست: `09390039833 / 09390039833`. سناریوی باگ: دکتر تست `09100652121` در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`.
