Files
clinicpro/.claude/prompt/context-separation-clinic-vs-doctor-booking.md
T
hamed f1258d206d 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.
2026-07-18 13:32:56 +03:30

446 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# جداسازی 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`.