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.
This commit is contained in:
@@ -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` استفاده شود، نه `<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).
|
||||
Reference in New Issue
Block a user