# منشیِ مشترک کلینیک: تخصیص یک منشی به چند پزشک با دسترسی محدود

## پروژه

`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` استفاده شود، نه `<select>` بومی).
  - create mutation به‌جای `doctor_uuid` تکی، `doctor_uuids: string[]` بفرستد.
  - برای منشیِ موجود، امکان ویرایش مجموعه‌ی پزشکان (فراخوانی endpoint sync وظیفه‌ی ۱). نمایش پزشکانِ فعلیِ هر منشی (از `listByClinic` که ردیف‌ها را per پزشک می‌دهد — گروه‌بندی بر اساس منشی/موبایل).
  - نمایش پیام برای پزشکانِ skip‌شده به‌خاطر سقف پلن.
- تایپ `Secretary` در `types/index.ts`: افزودن `doctors?: { uuid: string; name: string }[]` یا `doctor_uuids`.
- تست vitest: رندر multi-select، ارسال آرایه در mutation، گروه‌بندی منشی با چند پزشک. (تست‌ها روی host اجرا شوند: `npx vitest --run <file>` — node_modules داخل ddev برای esbuild لینوکسی نیست.)

## نکات مهم

- **بدون تغییر schema.** فقط ردیف‌های `DoctorSecretary` اضافه/حذف می‌شوند. اگر لازم شد ردیف حذف شود، هماهنگ با رفتار فعلی `deactivate` (soft `setActive(false)`) تصمیم بگیر — برای sync، حذف واقعی یا غیرفعال‌سازی را یکدست انتخاب کن و مستند کن.
- **سازگاری قدیمی:** مسیر تک‌پزشکیِ `doctor_uuid` و جریان منشیِ owner=doctor نباید بشکند.
- **envelope:** پاسخ‌ها با `$this->success(...)`/`$this->paginated(...)`؛ لیست‌های admin با array hydration. سمت فرانت single = `data?.data` (ممکن double-nested)، paginated items = `data?.data`.
- **context منشی:** ورود منشی یک context per کلینیک می‌سازد (`AuthController.php:~737`)؛ scope در هر request از `UserActiveContext` خوانده می‌شود. تغییرات وظیفه‌ی ۲ در همان لایه‌ی resolve اعمال شود، نه در ساخت context.
- **permissionها:** ماتریس دسترسی منشی (`DEFAULT_PERMISSIONS`) دست‌نخورده؛ scopeِ پزشک یک لایه‌ی مستقل و مقدم بر permission است.
- **RTL/فارسی، تاریخ‌ها Unix + شمسی.**
- بعد از اتمام: `graphify update .` (طبق قانون؛ اول commit).
