Files
clinicpro/.claude/prompt/clinic-shared-secretary-multi-doctor.md
hamed 1779e0d6de feat(secretary): implement multi-doctor assignment for clinic secretaries
- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments.
- Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary.
- Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones.
- Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output.
- Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships.
- Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors.
- Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions.
- Updated frontend components to support multi-select for doctors in the secretary management UI.
2026-07-18 08:49:04 +03:30

13 KiB

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

پروژه

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

#[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

// اعمال فیلتر منشیِ کلینیک — همه‌ی پزشکان کلینیک، نه تخصیص‌یافته‌ها:
if ($filterType === 'clinic') {
    $qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
       ->andWhere('c = :clinic')->setParameter('clinic', $filterValue);
}
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

// 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).