# اتصال «بیمه‌ی سرویس» در صفحه سرویس‌های کلینیک به سامانه‌ی واقعی پوشش بیمه و مطالبات ## پروژه `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 .`