Files
clinicpro/.claude/prompt/service-insurance-per-insurer-coverage.md
T
hamed 27840da78d feat: integrate insurance coverage management for clinic services
- Updated NewSessionPage to calculate patient share based on insurance coverage rules.
- Refactored billing calculations to utilize new patientShareOf function for service items.
- Enhanced API documentation to reflect changes in service coverage structure.
- Implemented ServiceInsuranceModal for managing insurance coverage per service.
- Added UI components for displaying and editing insurance coverage details.
- Removed obsolete toggle switch styles and adjusted CSS for new components.
- Ensured backend endpoints support both service_item_id and service_item_uuid for flexibility.
2026-06-24 04:23:50 +03:30

182 lines
14 KiB
Markdown
Raw 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.
# اتصال «بیمه‌ی سرویس» در صفحه سرویس‌های کلینیک به سامانه‌ی واقعی پوشش بیمه و مطالبات
## پروژه
`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
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13.5 }}>
<input
type="checkbox"
checked={itemForm.watch('insurance_covered') ?? false}
onChange={(e) => itemForm.setValue('insurance_covered', e.target.checked)}
/>
این خدمت شامل بیمه می‌شود
</label>
{itemForm.watch('insurance_covered') && (
<div className="field">
<label>قیمت با بیمه (ریال)</label>
<PriceInput
value={itemForm.watch('insurance_price_rials') ?? 0}
onChange={(v) => itemForm.setValue('insurance_price_rials', v)}
placeholder="سهم بیمار با بیمه"
min={0}
/>
</div>
)}
```
این مقادیر فقط روی `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 .`