feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays

- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
@@ -0,0 +1,445 @@
# جداسازی 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`.