- 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.
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 لازم نیست.
مشکل / هدف
سه شکاف وجود دارد که باید پر شود:
- نوشتن تکپزشکی: هر مسیر نوشتن فقط یک پزشک میگیرد.
POST /api/v1/secretaryفقط یکdoctor_uuidمیپذیرد؛ برای تخصیص به N پزشک باید N بار صدا زد. هیچ سرویس/endpoint اتمیک برای چند پزشک یا برای «همگامسازی مجموعهی پزشکانِ یک منشی» وجود ندارد. اصلاً پوشهیsrc/Secretary/Service/نیست (منطق داخل کنترلر). - UI تکانتخابی: فرم کلینیک در
MySecretariesPage.tsxپزشک را تکانتخابی میگیرد؛ multi-select و ویرایش لیست پزشکانِ منشیِ موجود نیست. - ⚠️ عدم اعمال 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).
- سقف پلن: توجه کن
countActiveByDoctorper-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شده بهخاطر سقف پلن.
- picker پزشک را از تکانتخابی به multi-select تبدیل کن (چکلیستِ پزشکانِ کلینیک؛ از الگوی
- تایپ
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(softsetActive(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).