# منشیِ مشترک کلینیک: تخصیص یک منشی به چند پزشک با دسترسی محدود ## پروژه `clinicpro` (Backend Symfony + پنل ادمین React — همان ریپو). ## زمینه در یک کلینیک که چند پزشک دارد، مدیر کلینیک می‌خواهد **یک منشی را به یک یا چند پزشکِ همان کلینیک** تخصیص دهد، به‌طوری‌که دسترسی آن منشی **فقط به پزشکانِ تعیین‌شده** محدود باشد (نه همه‌ی پزشکان کلینیک). این ارتباط باید many-to-many، و بعداً قابل افزودن/حذف بدون تغییر ساختاری باشد. **مهم — schema از قبل آماده است:** موجودیت `DoctorSecretary` (`src/Secretary/Entity/DoctorSecretary.php`) یک join row است: `(doctor + secretary User + owner_type[doctor|clinic] + clinic? + permissions json)` با unique روی `(doctor_id, secretary_id, owner_type)`. یعنی یک منشیِ user همین حالا می‌تواند **چند ردیف** داشته باشد (یکی per پزشک). Repository هم متد `findDoctorsBySecretaryInClinic($user, $clinic)` را دارد که دقیقاً «پزشکانِ تخصیص‌یافته‌ی این منشی در کلینیک» را برمی‌گرداند. **هیچ migration/تغییر schema لازم نیست.** ## مشکل / هدف سه شکاف وجود دارد که باید پر شود: 1. **نوشتن تک‌پزشکی:** هر مسیر نوشتن فقط یک پزشک می‌گیرد. `POST /api/v1/secretary` فقط یک `doctor_uuid` می‌پذیرد؛ برای تخصیص به N پزشک باید N بار صدا زد. هیچ سرویس/endpoint اتمیک برای چند پزشک یا برای «هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشی» وجود ندارد. اصلاً پوشه‌ی `src/Secretary/Service/` نیست (منطق داخل کنترلر). 2. **UI تک‌انتخابی:** فرم کلینیک در `MySecretariesPage.tsx` پزشک را تک‌انتخابی می‌گیرد؛ multi-select و ویرایش لیست پزشکانِ منشیِ موجود نیست. 3. **⚠️ عدم اعمال scope (هسته‌ی خواسته):** منشیِ کلینیک الان **همه‌ی پزشکان کلینیک** را می‌بیند. لیست نوبت با `d MEMBER OF c.doctors` فیلتر می‌شود و گیت رزرو فقط عضویت در کلینیک را چک می‌کند — نه پزشکانِ تخصیص‌یافته. فقط داشبورد درست scope می‌شود (`findDoctorsBySecretaryInClinic`). بدون رفع این، «دسترسی محدود به پزشکان تعیین‌شده» فقط ظاهری است. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Secretary/Entity/DoctorSecretary.php` | join row؛ `OWNER_CLINIC`، `DEFAULT_PERMISSIONS`، `mergePermissions()` | | `src/Secretary/Controller/SecretaryController.php` | `create` (خط ۳۹، تک‌پزشکی)، `update`/`show`/`deactivate`، `listByClinic` (خط ۲۴۱)، `canManage` (خط ۲۶۲) | | `src/Secretary/Repository/DoctorSecretaryRepository.php` | `findDoctorsBySecretaryInClinic` (خط ۹۲)، `findByClinic` (خط ۱۲۶)، `findActiveBySecretaryForClinic` (خط ۷۶)، `countActiveByDoctor` (خط ۲۴) | | `src/Appointment/Controller/MyAppointmentsController.php` | `resolveSecretaryFilter` (خط ۵۰۴)، اعمال فیلتر clinic (خط ۳۰۳)، `secretaryCanBookForDoctor` (خط ۴۷۳) | | `src/Dashboard/Controller/DashboardController.php` | خط ۴۱۶–۴۳۱ — الگوی درستِ scope با `findDoctorsBySecretaryInClinic` (مرجع کپی) | | `src/Patient/Controller/PatientController.php` | `resolveEntity()` خط ۱۱۹۸ — scope بیمار (clinic vs doctor) | | `assets/admin/pages/MySecretariesPage.tsx` | فرم مدیریت منشی؛ شاخه‌ی clinic (خط ۶۰۴)، picker تک‌انتخابی (خط ۷۳۷)، create mutation (خط ۶۵۴) | | `assets/admin/types/index.ts` | تایپ `Secretary`/`SecretaryPermissions` (خط ۳۲۶) — بدون آرایه‌ی پزشک | | `docs/api/secretary.md` | مستندات endpointها | ## وضعیت فعلی ### create تک‌پزشکی — `SecretaryController.php:39` ```php #[Route('/api/v1/secretary', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); // ← فقط یک پزشک // ... $ownerClinic = null; if ($currentUser->hasRole('ROLE_CLINIC')) { $ownerClinic = $this->clinicRepo->findByUser($currentUser); if ($ownerClinic === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $ownerClinic)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } } // ... // find-or-create secretary user، duplicate guard روی (doctor, secretary, ownerType) $secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic); // ← یک ردیف // ... } ``` ### scope ناقص برای نوبت — `MyAppointmentsController.php:303` و `:504` ```php // اعمال فیلتر منشیِ کلینیک — همه‌ی پزشکان کلینیک، نه تخصیص‌یافته‌ها: if ($filterType === 'clinic') { $qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') ->andWhere('c = :clinic')->setParameter('clinic', $filterValue); } ``` ```php private function resolveSecretaryFilter(User $user): ?array { // ... if ($clinic !== null) { $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); // فقط «آیا در کلینیک هست» if ($rel === null) return null; $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); return ['clinic', $clinic, $canView]; // ← مجموعه‌ی پزشکانِ مجاز را حمل نمی‌کند } // ... } ``` ### الگوی درست (داشبورد) — `DashboardController.php:~416` ```php // scope کلینیکِ منشی را به پزشکانِ تخصیص‌یافته محدود می‌کند: $doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic); ``` ## وظایف > هر وظیفه: پیاده‌سازی → تست (موفق/خطا/مرزی) → مستند → گزارش. یک وظیفه در هر مرحله. ### ۱. سرویس منشی + تخصیص چند‌پزشکیِ اتمیک (Backend) - پوشه/کلاس جدید `src/Secretary/Service/SecretaryService.php` بساز و منطق چاق فعلیِ `create` را به آن منتقل کن (SOLID؛ کنترلر فقط HTTP). - متد `assignToDoctors(User $currentUser, string $mobile, array $doctorUuids, array $meta): array` که برای **مدیر کلینیک**: - کلینیکِ مالک را از `$currentUser` می‌گیرد؛ هر `doctorUuid` باید عضو همان کلینیک باشد (`isDoctorInClinic`) وگرنه ۴۰۳/۴۲۲. - منشیِ user را find-or-create می‌کند (مثل کد فعلی: نقش `ROLE_SECRETARY`، نام، پسورد اختیاری). - برای هر پزشک یک `DoctorSecretary(doctor, user, OWNER_CLINIC, clinic)` می‌سازد؛ ردیف تکراری `(doctor, secretary, ownerType)` را **skip** کند (نه خطا). - permissions ورودی را روی همه‌ی ردیف‌های ساخته‌شده اعمال کند (`mergePermissions`). - همه در یک تراکنش؛ خروجی: لیست ردیف‌های نهایی + پزشکانِ skip‌شده. - endpointها: - `POST /api/v1/secretary` را طوری توسعه بده که **علاوه بر** `doctor_uuid` (سازگاری قدیمی)، آرایه‌ی `doctor_uuids: string[]` را هم بپذیرد؛ اگر آرایه آمد و کاربر `ROLE_CLINIC` است → `assignToDoctors`. رفتار تک‌پزشکیِ فعلی نشکند. - `PUT /api/v1/secretaries/clinic/{clinicUuid}/secretary/{secretaryUuid}/doctors` (یا مسیر مشابه) برای **هم‌گام‌سازی**: بدنه `doctor_uuids: string[]`؛ ردیف‌های owner=clinicِ این منشی در این کلینیک را با مجموعه‌ی جدید sync می‌کند (افزودن نبودها، حذف/غیرفعال‌سازیِ اضافه‌ها). گارد: مالک کلینیک یا ادمین (`canManage`). - **سقف پلن:** توجه کن `countActiveByDoctor` per-doctor است؛ تخصیص یک منشی به N پزشک روی سقفِ هر پزشک حساب می‌شود. همین منطق per-doctor را برای هر پزشک در حلقه چک کن (اگر پزشکی به سقف رسید، همان پزشک را skip و در خروجی گزارش کن، بقیه ادامه یابند). - مستند: `docs/api/secretary.md` — بدنه‌ی جدید، مسیر sync، خطاها، مثال JSON واقعی. - تست PHPUnit (`tests/Secretary/...`): تخصیص چند‌پزشکی موفق، skipِ تکراری، ۴۰۳ برای پزشکِ خارج از کلینیک، sync (افزودن+حذف)، غیرمالک ۴۰۳. ### ۲. اعمال scope دسترسی به پزشکانِ تخصیص‌یافته (Backend) — هسته - `resolveSecretaryFilter` (`MyAppointmentsController.php:504`) در شاخه‌ی clinic: به‌جای بازگرداندن فقط Clinic، **مجموعه‌ی پزشکانِ تخصیص‌یافته** را با `findDoctorsBySecretaryInClinic($user, $clinic)` بگیر و در خروجی حمل کن (مثلاً `['clinic', $clinic, $canView, $doctorIds]`). - اعمال فیلتر (`:303`): به‌جای `d MEMBER OF c.doctors` برای همه‌ی کلینیک، `a.doctor IN (:doctorIds)` با آن مجموعه. اگر مجموعه خالی بود → صفحه‌ی خالی. - `secretaryCanBookForDoctor` (`:473`) در شاخه‌ی clinic: علاوه بر عضویت در کلینیک و permission، چک کن `$doctor` در `findDoctorsBySecretaryInClinic` باشد. - مسیرهای مشابه را هم هم‌سو کن: `PatientController::resolveEntity` (`:1198`) و Billing clinic-scope (اگر منشیِ کلینیک بیمار/مالی می‌بیند) باید به همان مجموعه‌ی پزشکان محدود شوند. الگو را از `DashboardController.php:~416` (که درست است) کپی کن — منطق مشترک را در سرویس/متد کمکی بگذار، نه کپیِ پراکنده. - تست: منشیِ تخصیص‌یافته به پزشک A (نه B) در کلینیکِ دارای A و B → لیست نوبت فقط A؛ رزرو برای B ممنوع؛ داشبورد و لیست هم‌خوان. ### ۳. Frontend — انتخاب چند پزشک و ویرایش تخصیص - در `MySecretariesPage.tsx` شاخه‌ی `isClinic`: - picker پزشک را از تک‌انتخابی به **multi-select** تبدیل کن (چک‌لیستِ پزشکانِ کلینیک؛ از الگوی `MultiCheckList`/`SearchableSelect` موجود استفاده کن — طبق قانون پروژه از `SearchableSelect` استفاده شود، نه `