Files
clinicpro/.claude/prompt/insurance-shared-calculation.md
T
hamed b3a5cda808 Refactor insurance share calculation logic in PatientService
- Consolidated the calculation of patient and insurance shares into a single method using BillingCalculator.
- Introduced new fields in PatientSession to store breakdown of insurance shares and patient share.
- Updated the API responses to include the new fields for consistency across payment, invoice, and claims dashboard.
- Added migration to backfill existing sessions with appropriate values for the new fields.
- Implemented tests to ensure the correctness of the new logic and verify that the breakdown sums to the gross total.
- Redesigned the claims dashboard to provide a more user-friendly overview of patient claims and their statuses.
2026-07-18 22:56:46 +03:30

203 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.
# اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و Claim
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React)
پرامپت همتا: `clinicpro/.claude/prompt/claims-dashboard-redesign.md` (بازطراحی صفحه `/admin/claims`) — **اول این پرامپت اجرا شود**، چون داشبورد Claims به فیلدهای محاسباتی این پرامپت وابسته است.
## زمینه
در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` یک سرویس دارای پوشش بیمه ساخته شده (`/admin/clinic-services/f3e46236-7ddd-49d0-a725-d731c74c24f7`) و برای بیمار `ad0a3d0e-5514-462c-9fcf-20748c1c5e46` ثبت شده است. در صفحه تکمیل پرداخت
`/admin/patients/ad0a3d0e-5514-462c-9fcf-20748c1c5e46/session/4f66d5c0-028f-424f-bec7-a10857f11c04/pay`
پوشش بیمه اعمال نمی‌شود و مبلغ قابل پرداخت بیمار برابر کل مبلغ سرویس نمایش داده می‌شود.
ریشه مشکل: **دو مسیر محاسباتی مستقل** وجود دارد و `PatientSession` هیچ ستونی برای سهم بیمه ندارد؛ بنابراین breakdown بیمه فقط بعد از ساخت `Invoice` وجود دارد و صفحه پرداخت اصلاً آن را نمی‌بیند.
## مشکل / هدف
۱. حذف محاسبه inline ویزیت در `PatientService::calculateFinalPrice()` و یکی‌کردن همه‌ی محاسبات روی `BillingCalculator`.
۲. ذخیره breakdown بیمه روی `PatientSession` تا صفحه پرداخت، فاکتور، سهم بیمار/بیمه، مانده و وضعیت پرداخت همگی از یک مقدار بخوانند.
۳. نمایش سهم بیمه پایه/تکمیلی در صفحه پرداخت.
۴. رفع ناسازگاری‌های فرمول مانده و over-payment guard.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Billing/Service/BillingCalculator.php` | تنها منبع درست محاسبه سهم‌ها (percent + franchise + ceiling) |
| `src/Billing/ValueObject/Money.php` | VO پول؛ `sub()` در صفر clamp می‌شود |
| `src/Insurance/Service/TenantInsuranceService.php` | `coverageRule()` و `coverageRuleForService()` — resolve قرارداد + override سرویس |
| `src/Insurance/Entity/TenantInsurance.php` | قرارداد: `coveragePercent`, `franchiseRials`, `annualCeilingRials`, `isActive`, `effectiveFrom/To` |
| `src/Insurance/Entity/TenantServiceCoverage.php` | override به ازای (قرارداد، serviceItem): `covered`, `coveragePercent`, `franchiseRials`, `ceilingRials` (null = ارث از قرارداد) |
| `src/ClinicService/Entity/ServiceItem.php` | `insuranceCovered` (گیت bool)، `priceRials`، `insurancePriceRials` (فعلاً dead data) |
| `src/Patient/Service/PatientService.php` | `calculateFinalPrice()`، `recomputeSettlement()`، `addSessionPayment()`، `updatePayment()` |
| `src/Patient/Entity/PatientSession.php` | `finalPriceRials`, `servicesTotalRials`, `discountRials`, `getPaidTotalRials()`, `getRemainingRials()` |
| `src/Billing/Service/InvoiceService.php` | ساخت فاکتور از session |
| `src/Patient/Controller/PatientController.php` | `POST /api/v1/session/{uuid}/payments` و لیست sessionها |
| `assets/admin/components/session/PaymentStep.tsx` | UI صفحه پرداخت (مشترک با `NewSessionPage`) |
| `assets/admin/components/InvoiceSummaryModal.tsx` | مودال فاکتور بیمار |
## وضعیت فعلی
`src/Billing/Service/BillingCalculator.php` (منطق درست):
```php
$baseShare = $total->percent($base->coveragePercent);
if ($base->ceilingRials !== null) $baseShare = $baseShare->min(new Money($base->ceilingRials));
$remaining = $total->sub($baseShare);
// تکمیلی روی باقی‌مانده اعمال می‌شود، نه روی کل
$suppShare = $remaining->percent($supplementary->coveragePercent);
...
$franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0));
$patient = $remaining->add($franchise)->min($total);
```
`src/Patient/Service/PatientService.php::calculateFinalPrice()` — سرویس‌ها از `BillingCalculator` می‌آیند اما **ویزیت inline حساب می‌شود** و آن هم از درصدهای ذخیره‌شده روی session، نه از قرارداد:
```php
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
$visitShare = (int) round($afterSupp);
```
`assets/admin/components/session/PaymentStep.tsx:117-122` — کل محاسبه سمت کلاینت:
```ts
const finalPrice = session.final_price_rials ?? 0;
const discountRials = session.discount_rials ?? 0;
const payable = Math.max(0, finalPrice - discountRials);
```
هیچ فیلد `base_insurance_rials` / `supplementary_rials` در پاسخ session وجود ندارد، پس سهم بیمه اصلاً قابل نمایش نیست.
`InvoiceSummaryModal.tsx:66-73` — مانده در شاخه‌ی session سهم بیمه را نادیده می‌گیرد:
```ts
const remaining = session
? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
: inv ? (paid ? 0 : inv.patient_rials) : 0;
```
## وظایف
### ۱. دیباگ اولیه: چرا پوشش بیمه اعمال نشده؟
قبل از هر تغییر کد، با داده واقعی بررسی کن (روی ddev):
```bash
ddev exec php bin/console dbal:run-sql "SELECT id, insurance_covered, price_rials, insurance_price_rials FROM service_item WHERE uuid = 'f3e46236-7ddd-49d0-a725-d731c74c24f7'"
ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_insurance WHERE entity_type='clinic' AND is_active=1"
ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_service_coverage"
ddev exec php bin/console dbal:run-sql "SELECT uuid, insurance_base_id, insurance_supplementary_id, services_total_rials, final_price_rials, discount_rials FROM patient_session WHERE uuid = '4f66d5c0-028f-424f-bec7-a10857f11c04'"
```
سه fail-point محتمل را مشخص کن و در گزارش بنویس کدام‌یک بوده است:
- `service_item.insurance_covered = 0` → گیت بسته است.
- `patient_session.insurance_base_id = NULL` → بیمه هنگام ثبت سرویس به session نچسبیده (احتمالاً UI ثبت سرویس بیمه بیمار را ارسال نمی‌کند).
- `tenant_insurance` برای این کلینیک وجود ندارد یا `effective_from/to` بازه‌ی تاریخ session را پوشش نمی‌دهد.
اگر fail-point «بیمه به session نچسبیده» بود، مسیر ثبت سرویس برای بیمار را هم اصلاح کن تا `insurance_base_id`/`insurance_supplementary_id` از بیمه‌ی ثبت‌شده‌ی بیمار پر شود.
### ۲. یکی‌کردن محاسبه ویزیت
در `PatientService::calculateFinalPrice()` محاسبه inline ویزیت را حذف کن و مثل خطوط سرویس از `TenantInsuranceService::coverageRule()` + `BillingCalculator::calculateItem()` استفاده کن — دقیقاً همان چیزی که `InvoiceService.php:48-51` انجام می‌دهد.
فیلدهای `base_insurance_discount_percent` / `supplementary_discount_percent` روی session را به‌عنوان **snapshot** نگه دار (backward compat) اما دیگر ورودی محاسبه نباشند؛ بعد از محاسبه از روی درصدهای قرارداد پرشان کن.
### ۳. ذخیره breakdown بیمه روی PatientSession
سه ستون جدید به `PatientSession` اضافه کن (nullable-not، default 0):
- `baseInsuranceRials`
- `supplementaryInsuranceRials`
- `patientShareRials`
قرارداد: `patientShareRials` همان چیزی است که `finalPriceRials` باید باشد (سهم بیمار **قبل** از تخفیف دستی). یعنی:
```
servicesTotalRials = مجموع مبلغ اصلی همه اقلام (ویزیت + سرویس‌ها)
baseInsuranceRials + supplementaryInsuranceRials + patientShareRials = servicesTotalRials
finalPriceRials = patientShareRials
payable = finalPriceRials - discountRials
remaining = max(0, payable - paidTotal)
```
هر جا session ذخیره یا بازمحاسبه می‌شود این سه ستون هم نوشته شوند. migration لازم است:
```bash
ddev exec php bin/console make:migration
ddev exec php bin/console doctrine:migrations:migrate -n
```
**Backfill:** برای sessionهای موجود، مقدار `patientShareRials = finalPriceRials` و دو ستون بیمه = 0 ست شود تا رفتار قدیمی نشکند.
### ۴. حذف تکرار فرمول مانده و over-payment guard
- `PatientService::updatePayment()` (حدود `:419-421`) که `payable = finalPrice - discount` را inline دوباره می‌سازد را حذف کن و از `PatientSession::getRemainingRials()` استفاده کن — همان چیزی که `addSessionPayment()` (`:570`) استفاده می‌کند.
- در `updatePayment` هنگام ویرایش یک پرداخت موجود، مبلغ همان پرداخت باید از `paidTotal` کسر شود وگرنه ویرایش به سمت بالا اشتباهاً reject می‌شود. این edge case را تست کن.
### ۵. خروجی API
در `toArray()` مربوط به session (مسیر `GET /api/v1/patient/{uuid}/sessions` و پاسخ‌های `POST/PATCH /api/v1/session/{uuid}/...`) این فیلدها اضافه شوند:
```json
{
"services_total_rials": 0,
"base_insurance_rials": 0,
"supplementary_insurance_rials": 0,
"patient_share_rials": 0,
"final_price_rials": 0,
"discount_rials": 0,
"paid_total_rials": 0,
"remaining_rials": 0,
"insurance_base_title": null,
"insurance_supplementary_title": null
}
```
`remaining_rials` را سرور بدهد تا کلاینت دیگر مانده را خودش نسازد.
### ۶. UI صفحه پرداخت
در `assets/admin/components/session/PaymentStep.tsx`:
- به بخش خلاصه مبالغ (`:196-209`) این ردیف‌ها اضافه شود، **فقط وقتی مقدارشان > 0 است**:
- `سهم بیمه پایه` (+ نام بیمه)
- `سهم بیمه تکمیلی`
- `سهم بیمار`
- `payable` دیگر client-side ساخته نشود؛ از `remaining_rials` سرور استفاده شود.
- ترتیب نمایش: هزینه کل خدمات → سهم بیمه پایه → سهم بیمه تکمیلی → سهم بیمار → تخفیف → مبلغ نهایی قابل پرداخت → پرداخت‌شده → مانده.
**احتیاط:** این کامپوننت با `NewSessionPage` (ویزارد ۳ مرحله‌ای) مشترک است — هر دو مسیر باید تست شوند.
### ۷. اصلاح مودال فاکتور
در `assets/admin/components/InvoiceSummaryModal.tsx`:
- دو شاخه‌ی واگرای «با session» و «بدون session» را یکی کن؛ هر دو باید ستون‌های یکسان نشان دهند: جمع خدمات / سهم بیمه پایه / سهم بیمه تکمیلی / سهم بیمار / تخفیف / مبلغ نهایی / پرداخت‌شده / مانده.
- `remaining` را از `remaining_rials` سرور بگیر، نه از فرمول محلی.
- منطق «وضعیت واقعی پرداخت مستقل از وضعیت فریزشده فاکتور» (تسویه‌شده اگر مانده صفر) عمداً وجود دارد — حفظش کن.
## نکات مهم
- تکمیلی روی **باقی‌مانده بعد از پایه** اعمال می‌شود، نه روی کل. این قاعده در `BillingCalculator` درست است و نباید تغییر کند.
- `ServiceItem.insurancePriceRials` فعلاً write-only است و هیچ محاسبه‌ای نمی‌خواندش. یا آن را به‌عنوان «مبلغ ثابت پوشش» وارد `BillingCalculator` کن (اولویت بالاتر از percent) یا از UI و `toArray()` حذفش کن — تصمیم را در گزارش بنویس. حالت نصفه‌کاره نگه‌داشتنش قابل قبول نیست.
- `annualCeilingRials` امروز به‌صورت **سقف هر قلم** اعمال می‌شود در حالی که نامش سقف سالانه است. انباشت سالانه‌ای در کد نیست. رفتار فعلی را تغییر نده اما در کامنت و در گزارش صریح ذکرش کن.
- مرز ریال/تومان: ورودی‌های UI تومان‌اند، API ریال. از `tomanToRial` / `rialToToman` در `lib/utils.ts` استفاده شود (`RIAL_PER_TOMAN = 10`).
- `Money::sub()` در صفر clamp می‌شود و مقدار منفی نمی‌پذیرد — روی مبالغ سهم‌ها به آن تکیه کن، `max(0, ...)` دستی ننویس.
- قیمت سرویس در session هرچه caller بفرستد ذخیره می‌شود، ولی فاکتور دوباره از `TariffService::resolvePrice()` برای سال جلالی جاری resolve می‌کند. این واگرایی را حل کن: session هم باید از `TariffService` قیمت بگیرد.
- همه controllerها از `BaseController` ارث می‌برند؛ پاسخ‌ها با `$this->success()` / `$this->paginated()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`.
- بعد از تغییر API، فایل‌های مربوطه در `clinicpro/docs/api/` به‌روز شوند.
## تست پذیرش
۱. سرویس `f3e46236-...` برای بیمار `ad0a3d0e-...` ثبت شود؛ در صفحه `/pay` باید سهم بیمه پایه و سهم بیمار جدا نمایش داده شوند و مبلغ قابل پرداخت = سهم بیمار باشد.
۲. همان session → مودال فاکتور: اعداد باید **دقیقاً** با صفحه پرداخت یکی باشند.
۳. پرداخت جزئی ثبت شود → مانده در هر دو صفحه یکسان کم شود.
۴. پرداخت کامل → وضعیت در هر دو جا «تسویه شده».
۵. سرویسی بدون پوشش بیمه → سهم بیمه ۰، رفتار قبلی بدون تغییر.
۶. ویرایش یک پرداخت موجود به مبلغ بالاتر → نباید اشتباهاً «بیش از مانده» reject شود.