From 1779e0d6de4a1bdf81b2b92d41e94f365257b0dc Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 08:49:04 +0330 Subject: [PATCH] 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. --- .../clinic-shared-secretary-multi-doctor.md | 134 ++++++++++++ assets/admin/pages/MySecretariesPage.tsx | 180 +++++++++++---- .../pages/MySecretariesPageClinic.test.tsx | 58 +++++ assets/admin/types/index.ts | 1 + config/services.yaml | 4 + docs/api/secretary.md | 83 ++++++- .../Controller/MyAppointmentsController.php | 25 ++- .../Controller/SecretaryController.php | 73 ++++++ src/Secretary/Entity/DoctorSecretary.php | 1 + .../Repository/DoctorSecretaryRepository.php | 33 ++- src/Secretary/Service/SecretaryService.php | 207 ++++++++++++++++++ tests/Secretary/ClinicSharedSecretaryTest.php | 122 +++++++++++ .../SecretaryAppointmentScopeTest.php | 77 +++++++ 13 files changed, 947 insertions(+), 51 deletions(-) create mode 100644 .claude/prompt/clinic-shared-secretary-multi-doctor.md create mode 100644 assets/admin/pages/MySecretariesPageClinic.test.tsx create mode 100644 src/Secretary/Service/SecretaryService.php create mode 100644 tests/Secretary/ClinicSharedSecretaryTest.php create mode 100644 tests/Secretary/SecretaryAppointmentScopeTest.php diff --git a/.claude/prompt/clinic-shared-secretary-multi-doctor.md b/.claude/prompt/clinic-shared-secretary-multi-doctor.md new file mode 100644 index 00000000..e5a04022 --- /dev/null +++ b/.claude/prompt/clinic-shared-secretary-multi-doctor.md @@ -0,0 +1,134 @@ +# منشیِ مشترک کلینیک: تخصیص یک منشی به چند پزشک با دسترسی محدود + +## پروژه + +`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` استفاده شود، نه ` setQ(e.target.value)} + placeholder="جستجوی پزشک..." + className="flex-1 text-[13px] bg-transparent outline-none text-[#525252] dark:text-[#D7D8ED]" + /> + + + + {/* چیپ‌های انتخاب‌شده */} + {selected.length > 0 && ( +
+ {selected.map((uuid) => { + const d = doctors.find((x) => x.uuid === uuid); + if (!d) return null; + return ( + toggle(uuid)} + className="inline-flex items-center gap-[4px] bg-[#EEF0FF] dark:bg-[#33365A] text-[#5559CE] dark:text-[#C7CEF4] text-[12px] px-[8px] py-[3px] rounded-full cursor-pointer" + > + {d.name} + × + + ); + })} +
+ )} + + {/* لیست پزشکان */} +
+ {filtered.length === 0 ? ( +

نتیجه‌ای یافت نشد

+ ) : ( + filtered.map((d) => { + const checked = selected.includes(d.uuid); + return ( + + ); + }) + )} +
+ + ); +} + function MySecretariesPageContent() { const qc = useQueryClient(); const { doctorUuid, dbUuid, primaryRole } = useAuthStore(); @@ -606,8 +723,7 @@ function MySecretariesPageContent() { const { maxSecretaries } = useSubscription(); const [tab, setTab] = useState(0); // 0: منشی های فعلی، 1: منشی های قبلی - const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(""); - const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? ""); + const activeDoctorUuid = doctorUuid ?? ""; const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState("add"); @@ -615,7 +731,7 @@ function MySecretariesPageContent() { const [deactivateTarget, setDeactivateTarget] = useState(null); // clinic: list of doctors - const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery< + const { data: clinicDoctorsData } = useQuery< ApiResponse<{ data: ClinicDoctor[] }> >({ queryKey: ["clinic-doctors", dbUuid], @@ -652,17 +768,28 @@ function MySecretariesPageContent() { }; const createMutation = useMutation({ - mutationFn: (form: FormState) => - api.post("/api/v1/secretary", { - doctor_uuid: activeDoctorUuid, + mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => { + const base = { mobile_number: form.telephone, name: `${form.name} ${form.family}`.trim(), national_code: form.national_code || null, address: form.address || null, permissions: { version: 1, resources: form.permission }, - }), - onSuccess: () => { - toast.success("منشی با موفقیت اضافه شد"); + }; + return api.post>( + "/api/v1/secretary", + isClinic + ? { ...base, doctor_uuids: doctorUuids } + : { ...base, doctor_uuid: activeDoctorUuid }, + ); + }, + onSuccess: (res: ApiResponse) => { + const skippedLimit = res?.data?.skipped_limit?.length ?? 0; + if (isClinic && skippedLimit > 0) { + toast.warning(`${skippedLimit} پزشک به‌دلیل محدودیت پلن اضافه نشد`); + } else { + toast.success("منشی با موفقیت اضافه شد"); + } setModalOpen(false); invalidate(); }, @@ -699,7 +826,6 @@ function MySecretariesPageContent() { const saving = createMutation.isPending || updateMutation.isPending; const handleAddClick = () => { - if (isClinic && !selectedDoctorUuid) return toast.error("ابتدا یک پزشک را انتخاب کنید"); if (atLimit) return toast.error(`حداکثر ${maxSecretaries} منشی مجاز است؛ برای افزودن، پنل را ارتقا دهید`); setModalMode("add"); setSelected(null); @@ -712,8 +838,8 @@ function MySecretariesPageContent() { setModalOpen(true); }; - const handleModalSubmit = (form: FormState) => { - if (modalMode === "add") createMutation.mutate(form); + const handleModalSubmit = (form: FormState, doctorUuids: string[]) => { + if (modalMode === "add") createMutation.mutate({ form, doctorUuids }); else if (modalMode === "edit" && selected) updateMutation.mutate({ uuid: selected.uuid, form }); }; @@ -723,27 +849,6 @@ function MySecretariesPageContent() {

لیست منشی ها

- {/* کلینیک: انتخاب پزشک */} - {isClinic && ( -
- - {clinicDoctorsLoading ? ( -

در حال بارگذاری...

- ) : clinicDoctors.length === 0 ? ( -

هیچ پزشکی در این کلینیک تعریف نشده است.

- ) : ( - ({ value: d.uuid, label: d.name }))} - value={selectedDoctorUuid} - onChange={(v) => setSelectedDoctorUuid(v ? String(v) : "")} - placeholder="یک پزشک را انتخاب کنید" - /> - )} -
- )} - {/* تب‌ها + دکمه افزودن */}
@@ -764,7 +869,6 @@ function MySecretariesPageContent() {