Files
clinicpro/.claude/prompt/per-doctor-insurance-contracts.md
hamed 5507b42fd8 feat: implement per-doctor insurance settings in multi-doctor clinics
- Updated InsuranceModal to include doctorUuid in the payload for insurance contracts.
- Enhanced TenantInsuranceContracts to allow selection of doctors and pass doctorUuid in API requests.
- Modified InsuranceController to handle doctorUuid for tenant insurance endpoints, ensuring contracts are stored per doctor.
- Updated API documentation to reflect the new optional doctor_uuid parameter for tenant insurance endpoints.
- Added tests to verify the functionality of per-doctor insurance contracts and ensure isolation of contracts between doctors.
2026-07-21 19:21:55 +03:30

224 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تنظیمات بیمه به‌ازای هر پزشک در کلینیک چندپزشکه
## زمینه
درصد پوشش و شرایط هر بیمه (فرانشیز، سقف تعهد سالانه، تاریخ اعتبار، پوشش خدمات) می‌تواند برای هر پزشک متفاوت باشد. در یک کلینیک با ۳ پزشک، بیمه‌ها باید برای هر پزشک جداگانه تنظیم شوند، نه یک‌بار در سطح کلینیک.
مدل داده **از قبل** این را پشتیبانی می‌کند: هم `TenantInsurance` و هم `EntityInsurancePricing` چندریختی (polymorphic) هستند و ستون‌های `entity_type` (`'doctor'` یا `'clinic'`) و `entity_id` دارند. **نیازی به تغییر Entity یا migration نیست.**
مشکل فقط در «رزولوِ کردن موجودیت هدف» و UI است:
- اندپوینت‌های **`insurance-pricing`** (`GET/PUT /api/v1/insurance-pricing`) **از قبل** پارامتر `doctor_uuid` را می‌پذیرند و از `resolveTargetEntity()` استفاده می‌کنند → قیمت‌گذاری ویزیت هم‌اکنون per-doctor کار می‌کند. ✅
- اما اندپوینت‌های **`tenant-insurances`** (که `coverage_percent`=درصد و `franchise_rials`/`annual_ceiling_rials`=شرایط را نگه می‌دارند) از `resolveEntity($user)` استفاده می‌کنند که برای مالک کلینیک همیشه **سطح کلینیک** (`entity_type='clinic'`, `entity_id=clinicId`) برمی‌گرداند → یک مجموعه قرارداد مشترک برای هر ۳ پزشک. ❌ **این باگ اصلی است.**
## مشکل / هدف
قراردادهای بیمه (`tenant-insurances`) و پوشش خدمات (`service-coverage`) را طوری کن که مالک کلینیک (یا کاربر با مجوز) بتواند برای **هر پزشکِ** کلینیک، بیمه‌ها را جداگانه تنظیم کند — دقیقاً با همان الگویی که برای `insurance-pricing` پیاده شده (`doctor_uuid` + `resolveTargetEntity`). سپس در پنل ادمین یک انتخاب‌گر پزشک اضافه شود تا کاربر پزشک هدف را برگزیند.
**Spec (EN):** In a multi-doctor clinic, insurance contracts and their coverage percent / franchise / ceiling / service-coverage must be stored per doctor, not once per clinic. Extend the six `tenant-insurances` endpoints to accept an optional `doctor_uuid` and resolve the target entity via the existing `resolveTargetEntity()` (falling back to today's behavior when absent). Add a doctor selector to the admin insurance page for clinic owners that have more than one doctor, threading `doctor_uuid` through every query and mutation.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Insurance/Controller/InsuranceController.php` | ۶ اندپوینت `tenant-insurances` که باید `doctor_uuid` بپذیرند |
| `src/Patient/Security/PatientRecordScopeResolver.php` | چرا مالک کلینیک همیشه clinic-level می‌شود (فقط برای درک — تغییر نکند) |
| `src/Insurance/Service/TenantInsuranceService.php` | متدهای `activate` / `deactivate` / `setServiceCoverage` (بررسی امضاها) |
| `src/Insurance/Entity/TenantInsurance.php` | مدل چندریختی — بدون تغییر |
| `src/Insurance/Entity/EntityInsurancePricing.php` | مدل چندریختی — بدون تغییر |
| `assets/admin/components/TenantInsuranceContracts.tsx` | UI اصلی؛ باید انتخاب‌گر پزشک + پاس‌دادن `doctor_uuid` اضافه شود |
| `assets/admin/pages/InsurancePricingPage.tsx` | صفحه میزبان؛ محل قرارگرفتن انتخاب‌گر پزشک |
| `assets/admin/components/InsuranceModal.tsx` | `buildInsurancePayload` — باید `doctor_uuid` را در payload بگنجاند |
| `assets/admin/components/ServiceInsuranceModal.tsx` | پوشش خدمات per-contract — باید `doctor_uuid` را پاس دهد |
| `docs/api/insurance.md` | مستندسازی پارامتر جدید `doctor_uuid` روی اندپوینت‌های tenant-insurances |
## وضعیت فعلی
### الگوی مرجع که از قبل per-doctor است (باید تکرار شود)
`resolveTargetEntity()` هم‌اکنون در کنترلر موجود است و درست کار می‌کند:
```php
// src/Insurance/Controller/InsuranceController.php:57
private function resolveTargetEntity(User $user, ?string $doctorUuid, string $action): array
{
if ($doctorUuid === null || $doctorUuid === '') {
[$type, $id] = $this->resolveEntity($user);
return [$type, $id, null]; // رفتار قبلی حفظ می‌شود
}
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
return ['unknown', null, $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404)];
}
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
}
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
if ($this->permChecker->can($user, $clinic, 'services', $action)) {
return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null];
}
}
return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)];
}
```
و در `getInsurancePricing`/`saveInsurancePricing` این‌طور مصرف می‌شود:
```php
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) { return $err; }
```
### اندپوینت‌هایی که هنوز clinic-only هستند (باید اصلاح شوند)
هر ۶ اندپوینت زیر از `resolveEntity($user)` استفاده می‌کنند و `doctor_uuid` را نادیده می‌گیرند:
```php
// src/Insurance/Controller/InsuranceController.php
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
public function listTenantInsurances(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user); // ← clinic-level برای مالک کلینیک
...
}
#[Route('/api/v1/billing/tenant-insurances', methods: ['POST'])]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user); // ←
...
}
// همین‌طور:
// updateTenantInsurance(string $uuid, ...) PATCH /tenant-insurances/{uuid}
// deactivateTenantInsurance(string $uuid, ...) DELETE /tenant-insurances/{uuid}
// listServiceCoverage(string $uuid, ...) GET /tenant-insurances/{uuid}/service-coverage
// setServiceCoverage(string $uuid, ...) PUT /tenant-insurances/{uuid}/service-coverage
```
### چرا مالک کلینیک clinic-level می‌شود
```php
// src/Patient/Security/PatientRecordScopeResolver.php:40
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return PatientRecordScope::forClinic($clinic?->getId()); // entity_type='clinic'
}
```
این رزولوِر عمداً برای پرونده‌ها clinic-level است و **نباید تغییر کند**؛ راه‌حل، عبور `doctor_uuid` از سمت کنترلر است (مثل insurance-pricing).
### فرانت‌اند فعلی — بدون انتخاب پزشک
```tsx
// assets/admin/components/TenantInsuranceContracts.tsx:46
const contractsQuery = useQuery({
queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
});
const pricingQuery = useQuery({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
```
هیچ مفهومی از «پزشک انتخاب‌شده» وجود ندارد؛ mutationها هم `doctor_uuid` نمی‌فرستند.
## وظایف
### ۱. Backend — عبور `doctor_uuid` در اندپوینت‌های tenant-insurances
در `InsuranceController.php`، شش اندپوینت `tenant-insurances` را از `resolveEntity($user)` به `resolveTargetEntity(...)` تغییر بده — دقیقاً مثل الگوی `insurance-pricing`:
- **`listTenantInsurances`** (GET): `doctor_uuid` را از `$request->query->get('doctor_uuid')` بگیر، `action='view'`. امضای متد به `(Request $request, #[CurrentUser] User $user)` تغییر کند.
- **`activateTenantInsurance`** (POST): `doctor_uuid` را از بدنه (`$data['doctor_uuid'] ?? null`) بگیر، `action='update'`.
- **`updateTenantInsurance`** (PATCH): `doctor_uuid` از بدنه، `action='update'`.
- **`deactivateTenantInsurance`** (DELETE): `doctor_uuid` از query، `action='update'`.
- **`listServiceCoverage`** (GET): `doctor_uuid` از query، `action='view'`.
- **`setServiceCoverage`** (PUT): `doctor_uuid` از بدنه، `action='update'`.
```php
#[Route('/api/v1/billing/tenant-insurances', methods: ['GET'])]
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) { return $err; }
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
// ... بقیه بدون تغییر
}
```
**نکته حیاتی درباره تطبیق مالکیت قرارداد:** در `update`/`deactivate`/`listServiceCoverage`/`setServiceCoverage` بررسی فعلی این است:
```php
if ($contract === null || $contract->getEntityType() !== $entityType || $contract->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'قرارداد یافت نشد', 404);
}
```
این بررسی باید **حفظ** شود؛ چون `$entityType/$entityId` حالا از `resolveTargetEntity` می‌آید، وقتی `doctor_uuid` داده شود قرارداد باید `entity_type='doctor'` و همان `entity_id` را داشته باشد — یعنی کاربر نمی‌تواند با پاس‌دادن `doctor_uuid`ِ یک پزشک، قرارداد پزشک دیگری را دستکاری کند. این رفتار درست است، فقط مطمئن شو ترتیب صحیح است (اول resolve، بعد تطبیق).
- امضای `TenantInsuranceService::activate/deactivate/setServiceCoverage` را بررسی کن؛ چون `$entityType/$entityId` را همان کنترلر پاس می‌دهد، معمولاً نیازی به تغییر سرویس نیست. اگر جایی مستقیم `resolveEntity` صدا زده می‌شود، آن را هم اصلاح کن.
### ۲. Backend — یکدست‌سازی و اعتبارسنجی
- در `setServiceCoverage`، بعد از resolve، این بررسی موجود است که section سرویس به همان tenant تعلق دارد:
```php
if ($section->getEntityType() !== $entityType || $section->getEntityId() !== $entityId) { ... 403 }
```
مطمئن شو با entity هدفِ per-doctor سازگار می‌ماند (سرویس‌های آن پزشک باید `entity_type='doctor'` باشند).
- Edge case: اگر `doctor_uuid` متعلق به پزشکی باشد که عضو کلینیکِ این کاربر **نیست**، `resolveTargetEntity` باید 403 برگرداند (منطق `findByDoctor` + `permChecker` این را پوشش می‌دهد). تأیید کن.
### ۳. Frontend — انتخاب‌گر پزشک در صفحه بیمه
در `TenantInsuranceContracts.tsx`:
- یک state جدید `selectedDoctorUuid: string | null` اضافه کن.
- فهرست پزشکان کلینیک را با `GET /api/v1/clinic/doctor-list/{clinicUuid}` بگیر (clinicUuid کلینیکِ فعال کاربر — از همان منبعی که بقیه صفحات کلینیک استفاده می‌کنند، مثلاً authStore/context؛ الگوی موجود را پیدا و تکرار کن).
- **فقط وقتی کاربر مالک کلینیک است و کلینیک بیش از یک پزشک دارد**، انتخاب‌گر را نشان بده. از کامپوننت `SearchableSelect` استفاده کن (قانون پروژه: هرگز `<select>` بومی). برای مطب تک‌پزشکه یا وقتی یک پزشک است، انتخاب‌گر پنهان بماند و `doctor_uuid` ارسال نشود (رفتار قبلی).
- `doctor_uuid` را در **query string** هر دو query بگنجان و در **queryKey** بیاور تا cache به‌ازای پزشک تفکیک شود:
```tsx
const dq = selectedDoctorUuid ? `?doctor_uuid=${selectedDoctorUuid}` : '';
const contractsQuery = useQuery({
queryKey: ['tenant-insurances', selectedDoctorUuid],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
});
const pricingQuery = useQuery({
queryKey: ['insurance-pricing', selectedDoctorUuid],
queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
});
```
- در `invalidate()` هم کلید را با `selectedDoctorUuid` هماهنگ کن.
- در mutationها:
- **POST/PATCH** (`saveMut`): `doctor_uuid` را داخل بدنه بفرست. `buildInsurancePayload` در `InsuranceModal.tsx` باید `doctor_uuid` را به payload اضافه کند (پارامتر جدید بگیرد یا مقدار از props).
- **DELETE / toggle status** (`toggleMut` از PATCH استفاده می‌کند → بدنه) و اگر مسیر DELETE هم زده می‌شود → query string.
- `ServiceInsuranceModal` (پوشش خدمات) هم `doctor_uuid` را در GET/PUT خودش پاس دهد.
### ۴. مستندات
`docs/api/insurance.md` را به‌روزرسانی کن: برای هر شش اندپوینت `tenant-insurances` پارامتر اختیاری `doctor_uuid` (query یا body) را مستند کن، با توضیح: «در نبودش رفتار پیش‌فرض (tenant کاربر) حفظ می‌شود؛ با آن، تنظیمات به‌ازای پزشک هدف اعمال می‌شود و نیازمند مالکیت/مجوز `services` روی کلینیکِ آن پزشک است.» به هم‌ترازی با `insurance-pricing` اشاره کن.
### ۵. تست
- **Backend (`ddev exec php bin/phpunit`)**: تست‌های موفق/خطا/مرزی برای `listTenantInsurances` و `activateTenantInsurance` با و بدون `doctor_uuid`:
- مالک کلینیک با `doctor_uuid` پزشک عضو → قرارداد با `entity_type='doctor'` ساخته/خوانده شود.
- `doctor_uuid` پزشکِ غیرعضو → 403.
- بدون `doctor_uuid` → رفتار قبلی (clinic-level) دست‌نخورده.
- جداسازی: قرارداد پزشک A نباید در فهرست پزشک B ظاهر شود.
- **Frontend (`yarn test` / `npx tsc --noEmit`)**: بدون خطای تایپ؛ رفتار انتخاب‌گر برای کلینیک تک‌پزشکه (مخفی) و چندپزشکه (نمایش).
## نکات مهم
- **بدون migration**: `TenantInsurance` و `EntityInsurancePricing` از قبل `entity_type/entity_id` دارند و uniqueِ آن‌ها شامل این ستون‌هاست (`uniq_tenant_insurance_version` = entity_type+entity_id+insurance_id+version)، پس ردیف‌های per-doctor مستقل‌اند. هیچ Entity تغییر نکند.
- **سازگاری عقب‌رو**: نبودِ `doctor_uuid` باید دقیقاً رفتار امروز را بدهد (`resolveEntity` fallback). این برای مطب‌های شخصی و مصرف‌کننده‌های موجود (`nobat724_front`, `clinic-pro-tauri`) حیاتی است — قرارداد فعلی نباید بشکند.
- **الگوی موجود را کپی کن، ابداع نکن**: `insurance-pricing` نقشهٔ راه است؛ همان `resolveTargetEntity`, همان resource `'services'`, همان action `'view'|'update'`.
- **BaseController**: پاسخ‌ها با `$this->success()` / `$this->error()`؛ کدهای خطا از `ErrorCodes`.
- **Frontend**: TanStack Query؛ خواندن single از `data?.data`، اما این کامپوننت قرارداد‌ها را از `data?.data?.data` می‌خواند (double-nested به‌خاطر `$this->success(['data' => ...])`) — این الگوی موجود را حفظ کن. `SearchableSelect` به‌جای `<select>`. رشته‌های UI فارسی.
- **مجوز کاربر غیرمالک**: مدیر/پرسنل کلینیک با مجوز `services` روی کلینیک هم می‌تواند بیمهٔ پزشکان را تنظیم کند (منطق `permChecker` از قبل این را پوشش می‌دهد).