diff --git a/.claude/prompt/context-separation-clinic-vs-doctor-booking.md b/.claude/prompt/context-separation-clinic-vs-doctor-booking.md new file mode 100644 index 00000000..ef94e221 --- /dev/null +++ b/.claude/prompt/context-separation-clinic-vs-doctor-booking.md @@ -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>(`/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` استفاده کن، نه `