# اتصال «بیمهی سرویس» در صفحه سرویسهای کلینیک به سامانهی واقعی پوشش بیمه و مطالبات
## پروژه
`clinicpro` (backend Symfony + admin React)
## زمینه
صفحه `https://clinic-pro.ddev.site/admin/clinic-services` هنگام تعریف سرویس فقط دو فیلد سادهی روی خود `ServiceItem` را ست میکند (این بخش قبلاً با پرامپت `service-insurance-coverage.md` اضافه شده):
- `insurance_covered` (boolean) — «این خدمت شامل بیمه میشود»
- `insurance_price_rials` (int, nullable) — «قیمت با بیمه»
اما سامانهی واقعی بیمه و مطالبات در پروژه چیز دیگری است و **به این دو فیلد وصل نیست**:
- `App\Insurance\Entity\TenantInsurance` — قرارداد بیمهی فعال هر مطب/کلینیک با یک بیمهگر (پایه یا تکمیلی)، دارای `coverage_percent`، `franchise_rials`، `annual_ceiling_rials`.
- `App\Insurance\Entity\TenantServiceCoverage` — قاعدهی پوشش **به ازای هر بیمهگر × هر سرویس** (`covered`, `coverage_percent`, `franchise_rials`, `ceiling_rials`). همین جدول است که محاسبهی سهم را تعیین میکند.
- `App\Insurance\ValueObject\CoverageRule` + `App\Billing\Service\BillingCalculator` — سهم بیمهی پایه/مکمل و سهم بیمار را از روی همین قاعده محاسبه میکنند.
- `App\Billing\Service\ClaimService::createFromInvoice()` — **مطالبات بیمه** را از `Invoice` میسازد؛ هر `ClaimItem` فقط وقتی ساخته میشود که `InvoiceItem` سهم بیمهی مثبت داشته باشد (`getBaseInsuranceRials()` / `getSupplementaryRials()`).
نتیجه: سرویسی که در صفحهی clinic-services «شامل بیمه» علامت خورده، **هیچ ردیفی در `TenantServiceCoverage` ندارد**، پس در `BillingCalculator` سهم بیمهاش صفر میشود و در `ClaimService` هیچ `ClaimItem` نمیسازد ⇒ در **مطالبات بیمه ظاهر نمیشود**. این همان شکافی است که این پرامپت میبندد.
> بافت ایران: بیمهگرها دو دستهاند — **پایه** (تأمین اجتماعی، سلامت، نیروهای مسلح، …) و **تکمیلی/مکمل** (دانا، آسیا، …). هر سرویس میتواند با هر بیمهگرِ فعالِ مطب درصد پوشش، فرانشیز و سقف متفاوت داشته باشد. مدل تکفیلدیِ `insurance_price_rials` این تنوع را پوشش نمیدهد؛ منبعِ حقیقت باید `TenantServiceCoverage` باشد.
## مشکل / هدف
هنگام ویرایش یک سرویس در صفحهی clinic-services، کاربر باید بتواند **پوشش بیمهی آن سرویس را به ازای هر بیمهگرِ فعال مطب** تعریف کند (`covered` + درصد پوشش + فرانشیز + سقف). این داده باید در `TenantServiceCoverage` نوشته شود تا مستقیماً وارد چرخهی `BillingCalculator → Invoice → ClaimService` (مطالبات بیمه) شود.
سه endpoint از قبل وجود دارند و باید **بازاستفاده** شوند (نه ساختن endpoint جدید):
- `GET /api/v1/billing/tenant-insurances` → لیست قراردادهای بیمهی فعال مطب.
- `GET /api/v1/billing/tenant-insurances/{uuid}/service-coverage` → پوششهای یک قرارداد.
- `PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` → ستکردن پوشش یک سرویس برای آن قرارداد.
## فایلهای مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/ClinicServicesPage.tsx` | فرم ویرایش سرویس — محل افزودن بخش پوشش بیمه |
| `assets/admin/components/ServiceTariffModal.tsx` | الگوی موجود modalِ مرتبط با سرویس (برای سبک UI ردیفها) |
| `src/Insurance/Controller/InsuranceController.php` | endpointهای `tenant-insurances` و `service-coverage` (خطوط ۲۹۹–۴۳۵) |
| `src/Insurance/Service/TenantInsuranceService.php` | `setServiceCoverage(...)` که ردیف `TenantServiceCoverage` را upsert میکند |
| `src/Insurance/Entity/TenantServiceCoverage.php` | موجودیت قاعدهی پوشش بهازای بیمهگر×سرویس |
| `src/Insurance/Entity/TenantInsurance.php` | قرارداد بیمهی فعال مطب |
| `src/Billing/Service/BillingCalculator.php` | محاسبهی سهم از روی `CoverageRule` |
| `src/Billing/Service/ClaimService.php` | ساخت مطالبات از Invoice (مصرفکنندهی نهاییِ این داده) |
| `src/ClinicService/Entity/ServiceItem.php` | فیلدهای سادهی `insurance_covered` / `insurance_price_rials` و `toArray()` (فقط `uuid` برمیگرداند) |
| `docs/api/insurance.md` | مستند API که باید بهروز شود |
## وضعیت فعلی
### فرم سرویس (frontend) — فقط دو فیلد ساده
`assets/admin/pages/ClinicServicesPage.tsx` خطوط ۳۹۷–۴۱۵:
```tsx
{itemForm.watch('insurance_covered') && (
itemForm.setValue('insurance_price_rials', v)}
placeholder="سهم بیمار با بیمه"
min={0}
/>
)}
```
این مقادیر فقط روی `ServiceItem` ذخیره میشوند و در `BillingCalculator`/`ClaimService` خوانده نمیشوند.
### endpoint موجود ستکردن پوشش (backend) — payload مورد انتظار
`src/Insurance/Controller/InsuranceController.php` خط ۴۰۹:
```php
#[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['PUT'])]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// ... resolveEntity + بررسی مالکیت قرارداد ...
$serviceItemId = (int) ($data['service_item_id'] ?? 0); // الزامی
$this->tenantInsuranceService->setServiceCoverage(
$contract,
$serviceItemId,
(bool) ($data['covered'] ?? true),
isset($data['coverage_percent']) ? (float) $data['coverage_percent'] : null,
isset($data['franchise_rials']) ? (int) $data['franchise_rials'] : null,
isset($data['ceiling_rials']) ? (int) $data['ceiling_rials'] : null,
);
return $this->success(['message' => 'پوشش خدمت ذخیره شد']);
}
```
> توجه: endpoint با `service_item_id` (id داخلی) کار میکند، اما frontend فقط `uuid` سرویس دارد — `ServiceItem::toArray()` فقط `uuid` میدهد. وظیفه ۳ این شکاف را حل میکند.
### موجودیت پوشش — قاعدهای که محاسبه را تعیین میکند
`TenantServiceCoverage`: `tenant_insurance_id`، `service_item_id`، `covered`، `coverage_percent` (decimal 5,2)، `franchise_rials`، `ceiling_rials`؛ یکتا روی `(tenant_insurance_id, service_item_id)`.
## وظایف
### ۱. افزودن «پوشش بیمه به ازای بیمهگر» به modal سرویس (frontend)
در `ClinicServicesPage.tsx`، داخل modal سرویس (بعد از فیلد «پرسنل مسئول»)، یک بخش جدید اضافه کن:
- با `useQuery` لیست قراردادهای فعال بیمه را از `GET /api/v1/billing/tenant-insurances` بگیر (نمایش نام بیمهگر + برچسب نوع پایه/تکمیلی).
- این بخش فقط در حالت **ویرایش سرویس** فعال باشد (در «سرویس جدید» چون هنوز id/uuid نیست، با پیام «ابتدا سرویس را ذخیره کنید» غیرفعال شود).
- برای هر قرارداد یک ردیف با کنترلها: `covered` (سوییچ)، `coverage_percent` (٪، ۰ تا ۱۰۰)، `franchise_rials` (PriceInput)، `ceiling_rials` (PriceInput).
- مقدار اولیهی هر ردیف از `GET /api/v1/billing/tenant-insurances/{uuid}/service-coverage` پر شود (ردیفِ متناظر با همین سرویس).
- ذخیرهی هر ردیف با `PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` و body:
```ts
{
service_item_uuid: string, // طبق وظیفه ۳ (یا service_item_id اگر id را expose کردی)
covered: boolean,
coverage_percent: number | null,
franchise_rials: number | null,
ceiling_rials: number | null,
}
```
از الگوهای موجود استفاده کن: `api.get/put` از `lib/api.ts`، `useMutation` + `qc.invalidateQueries`، `toast`، `PriceInput` برای مبالغ ریالی، `SearchableSelect` در صورت نیاز. سبک ردیفها را از `ServiceTariffModal.tsx` الگو بگیر.
```tsx
type CoverageRow = {
contractUuid: string;
insuranceName: string;
type: 'basic' | 'supplementary';
covered: boolean;
coveragePercent: number | null;
franchiseRials: number | null;
ceilingRials: number | null;
};
```
### ۲. شفافسازی نقش دو فیلد سادهی قدیمی
`insurance_covered` / `insurance_price_rials` را حذف نکن، اما نقششان را در UI روشن کن: چکباکس «این خدمت شامل بیمه میشود» بهعنوان **پرچم نمایشی/سریع** بماند و بخش جدیدِ «پوشش به ازای بیمهگر» بهعنوان **منبعِ واقعیِ محاسبه و مطالبات** معرفی شود (یک خط توضیح فارسی زیر بخش).
قبل از تصمیم، با grep بررسی کن `insurance_price_rials` و `isInsuranceCovered()` کجا مصرف میشوند (frontend و backend) تا چیزی نشکند.
### ۳. حل مشکل `service_item_id` در برابر `uuid` (backend)
frontend فقط `uuid` سرویس دارد ولی endpoint پوشش `service_item_id` میخواهد. گزینهی کمریسک را پیاده کن:
- **الف (ترجیح):** `setServiceCoverage` (controller + `TenantInsuranceService`) را طوری گسترش بده که اگر `service_item_id` نبود ولی `service_item_uuid` بود، id را از `ServiceItemRepository` پیدا کند و **اعتبارسنجی کند سرویس متعلق به همان مطب/کلینیکِ قرارداد است** (همان `resolveEntity`).
- خروجی `GET .../service-coverage` هم باید برای هر ردیف `service_item_uuid` بدهد (با join یا map از `service_item_id`) تا frontend ردیف درست را پیدا کند.
(اگر بهجای آن `id` را به `ServiceItem::toArray()` افزودی، همهی مصرفکنندههای آن را چک کن — قرارداد API تغییر میکند.)
### ۴. اطمینان از جریان به مطالبات بیمه
تأیید کن (و در صورت گسست، اصلاح کن) که زنجیره کامل برقرار است:
`TenantServiceCoverage` → `CoverageRule` → `BillingCalculator::calculateItem()` → ستشدن `baseInsuranceRials` / `supplementaryRials` روی `InvoiceItem` → `ClaimService::buildClaim()` که فقط آیتمهای دارای سهم مثبت را به `ClaimItem` تبدیل میکند.
اگر جایی این زنجیره بهجای `TenantServiceCoverage` از `insurance_price_rials` میخواند، همان نقطه را اصلاح کن تا سرویسِ بیمهداری که اینجا تعریف میشود واقعاً در **مطالبات بیمه** ظاهر شود. این هستهی خواستهی کاربر است.
### ۵. مستندسازی
`docs/api/insurance.md` را بهروز کن: payloadِ `PUT/GET .../service-coverage` (بهخصوص افزودن `service_item_uuid`)، و توضیح اینکه پوشش سرویس از این مسیر روی محاسبهی سهم و ساخت مطالبات اثر میگذارد.
## نکات مهم
- **معماری دوگانه را بههم نریز:** منبعِ حقیقتِ محاسبه `TenantServiceCoverage` است، نه `insurance_price_rials`.
- **پایه vs تکمیلی:** نوع بیمهگر از `Insurance` مرجع میآید (`InsuranceType: basic|supplementary`). در UI نوع را نشان بده؛ در `BillingCalculator` مکمل روی **باقیماندهی بعد از پایه** اعمال میشود.
- **چندمستأجری (tenant):** endpointهای `service-coverage` با `resolveEntity($user)` مالکیت قرارداد را چک میکنند؛ گسترش backend باید همان مالکیت را برای سرویس هم اعمال کند.
- **حالت ساخت اولیه:** بخش پوشش فقط در حالت ویرایش (سرویس باید id داشته باشد). سادهترین مسیر، یا بعد از ساختِ سرویس خودکار modal ویرایش باز شود.
- **مقادیر:** مبالغ با `PriceInput` و واحد ریال؛ `coverage_percent` عدد ۰ تا ۱۰۰.
- الگوهای پروژه: پاسخها با `$this->success()/$this->error()`؛ تاریخها Unix timestamp؛ admin با TanStack Query + RHF + Zod؛ JWT از `localStorage['clinicpro-auth']`.
- اگر `ServiceItem` تغییر کرد ⇒ migration با `doctrine:migrations:diff`.
- بعد از تغییر frontend: `ddev exec npx tsc --noEmit` و `ddev exec yarn dev`.
- پس از تغییر کد: `graphify update .`