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.
This commit is contained in:
hamed
2026-07-18 22:56:46 +03:30
parent 466b649988
commit b3a5cda808
16 changed files with 842 additions and 110 deletions
+210
View File
@@ -0,0 +1,210 @@
# بازطراحی صفحه Claims به داشبورد مدیریتی بیمار-محور
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React)
پیش‌نیاز: `clinicpro/.claude/prompt/insurance-shared-calculation.md` باید **قبل** از این پرامپت اجرا شود — ستون‌های سهم بیمه/بیمار روی session و منطق واحد محاسبه از آنجا می‌آید.
## زمینه
صفحه `/admin/claims` امروز یک لیست کارتی از رکوردهای Claim است: هر Claim یک کارت، و داخل هر کارت یک `<table>` تودرتو از اقلام. این نه DataTable استاندارد پروژه است و نه برای پیگیری پرونده‌های بیمه‌ی یک بیمار کاربردی — کاربر نمی‌تواند ببیند «بیمار X مجموعاً چقدر ادعا دارد و چقدر وصول شده».
## هدف
تبدیل صفحه به داشبورد دو سطحی:
- **سطح ۱ — لیست بیماران:** هر ردیف یک بیمار با تجمیع مبالغ و وضعیت کلی.
- **سطح ۲ — جزئیات یک بیمار:** همه‌ی درخواست‌های بیمه‌ی آن بیمار با جزئیات کامل و لاگ تغییرات.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/ClaimsPage.tsx` | صفحه فعلی (۳۶۶ خط) — بازنویسی می‌شود |
| `assets/admin/App.tsx:237` | ثبت route `claims`؛ نقش‌های `['doctor','clinic']` + `blockClinicScope` + `<FeatureGate feature="insurance">` |
| `src/Billing/Controller/BillingController.php:252` | `GET /api/v1/billing/claims` |
| `src/Billing/Controller/BillingController.php:286` | `POST /api/v1/billing/claims/{uuid}/{submit\|approve\|reject\|pay}` |
| `src/Billing/Controller/BillingController.php:332` | `GET /api/v1/billing/reports/insurance-debt` |
| `src/Billing/Entity/Claim.php` | `insuranceId`, `insuranceKind`, `totalClaimedRials`, `totalApprovedRials`, `totalPaidRials`, `status`, `TRANSITIONS` (`:26`), `rejectReason`, `submittedAt`, `settledAt` |
| `src/Billing/Entity/ClaimItem.php` | `invoiceItemId`, `claimedRials` |
| `src/Billing/Entity/Invoice.php` / `InvoiceItem.php` | مبالغ اصلی و سهم‌ها |
| `src/Billing/Service/ClaimService.php` | `buildClaim()` و انتقال وضعیت |
| `assets/admin/components/ui/` | `DataTable`, `Pagination`, `SearchableSelect`, `PageHeader`, `Modal`, `StatCard`, `StatusBadge`, `PersianDatePicker`, `FeatureGate` |
## وضعیت فعلی
`ClaimsPage.tsx` — کارت به‌ازای هر Claim، totalها به‌صورت یک رشته متنی درهم (`:271-276`):
```
ادعا … · تأیید … · پرداخت … · دلیل رد: …
```
و جدول داخلی (`:302-337`):
```tsx
<thead>
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
<th>شرح</th><th>تاریخ مراجعه</th>
<th style={{ textAlign: 'center' }}>تعداد</th>
<th style={{ textAlign: 'left' }}>مبلغ کل</th>
<th style={{ textAlign: 'left' }}>سهم بیمه (ادعا)</th>
<th style={{ textAlign: 'left' }}>تأییدشده</th>
</tr>
</thead>
```
مشکلات موجود که باید در بازطراحی رفع شوند:
- `GET /api/v1/billing/reports/insurance-debt` **دو بار** با دو query key جدا fetch می‌شود (`:116`, `:122`) — یکی برای پنل بدهی، یکی برای پر کردن dropdown بیمه.
- فیلترها با URL همگام نیستند (همه `useState`)؛ رفرش صفحه همه فیلترها را پاک می‌کند.
- وضعیت‌ها در `STATUS_META` محلی تعریف شده‌اند (`:69-75`) به‌جای `StatusBadge`.
- بدون `DoctorClaimsPage.tsx` اشتباه گرفته نشود — آن صفحه (`/admin/doctor-claims`) مربوط به ادعای مالکیت پروفایل پزشک است، نه بیمه.
## وظایف
### ۱. Backend — endpoint تجمیع بیمار-محور
endpoint جدید:
```
GET /api/v1/billing/claims/by-patient
```
پارامترها: `page`, `limit`, `search` (نام/موبایل/کد ملی), `insurance_id`, `doctor_id`, `clinic_id`, `status`, `payment_status`, `from`, `to` (Unix), `sort`, `dir`.
پاسخ paginated (`$this->paginated()`) با هر آیتم:
```json
{
"patient_uuid": "...",
"full_name": "...",
"mobile": "...",
"national_code": "...",
"claims_count": 0,
"total_services_rials": 0,
"total_insurance_rials": 0,
"total_patient_rials": 0,
"total_approved_rials": 0,
"total_paid_rials": 0,
"overall_status": "pending|submitted|approved|rejected|paid|mixed",
"last_activity_at": 0
}
```
قاعده `overall_status`: اگر همه Claimهای بیمار یک وضعیت دارند همان؛ در غیر این صورت `mixed`.
پیاده‌سازی با DQL و array hydration (`->getArrayResult()`)، تجمیع در SQL (`GROUP BY patient`) — نه در PHP روی کل رکوردها.
### ۲. Backend — endpoint جزئیات یک بیمار
```
GET /api/v1/billing/claims/by-patient/{patientUuid}
```
لیست همه Claimهای آن بیمار (paginated)، هر رکورد شامل:
```json
{
"uuid": "...",
"visit_date": 0,
"doctor_name": "...",
"clinic_name": "...",
"service_title": "...",
"service_base_rials": 0,
"coverage_percent": 0,
"coverage_rials": 0,
"patient_share_rials": 0,
"insurance_share_rials": 0,
"insurance_title": "...",
"insurance_kind": "base|supplementary",
"status": "...",
"submitted_at": 0,
"settled_at": 0,
"tracking_number": null,
"reject_reason": null,
"description": null,
"allowed_transitions": ["submit"],
"logs": [
{ "at": 0, "from": "pending", "to": "submitted", "by": "نام کاربر", "note": null }
]
}
```
`allowed_transitions` از `Claim::TRANSITIONS` بیاید تا فرانت دکمه‌های مجاز را خودش hardcode نکند.
### ۳. Backend — شماره پیگیری و لاگ تغییرات
دو مورد در دیتابیس وجود ندارند و باید اضافه شوند:
**الف) `trackingNumber`** روی `Claim` (string nullable) — شماره پرونده/پیگیری بیمه. هنگام `submit` قابل ورود باشد و در `POST /api/v1/billing/claims/{uuid}/submit` به‌عنوان فیلد اختیاری بدنه پذیرفته شود.
**ب) `ClaimStatusLog`** — entity جدید:
| فیلد | نوع |
|------|-----|
| `claimId` | int |
| `fromStatus` | string nullable |
| `toStatus` | string |
| `note` | text nullable |
| `createdBy` | userId |
| `createdAt` | Unix int |
در `ClaimService` هر جا انتقال وضعیت انجام می‌شود یک رکورد لاگ نوشته شود. migration لازم است:
```bash
ddev exec php bin/console make:migration
ddev exec php bin/console doctrine:migrations:migrate -n
```
**Backfill:** برای Claimهای موجود از `submittedAt` / `settledAt` رکوردهای لاگ تقریبی ساخته شود تا صفحه جزئیات برای داده قدیمی خالی نباشد.
### ۴. Frontend — سطح ۱: DataTable بیماران
`ClaimsPage.tsx` بازنویسی شود با `DataTable` استاندارد پروژه (نه کارت):
ستون‌ها: نام بیمار | موبایل | کد ملی | تعداد درخواست | مجموع خدمات | مجموع سهم بیمه | مجموع سهم بیمار | وضعیت کلی (`StatusBadge`)
بالای جدول: ردیف `StatCard` با مجموع کل خدمات / کل سهم بیمه / وصول‌شده / مانده وصول‌نشده — از `GET /api/v1/billing/reports/insurance-debt` که باید **فقط یک بار** fetch شود (یک `useQuery`، خروجی‌اش هم پنل و هم options فیلتر بیمه را بدهد).
کلیک روی ردیف → سطح ۲.
### ۵. Frontend — سطح ۲: جزئیات بیمار
مسیر جدید `/admin/claims/:patientUuid` در `App.tsx` با همان roleها و `<FeatureGate feature="insurance">`.
- `PageHeader` با breadcrumb: پرونده‌های بیمه ← نام بیمار.
- `DataTable` از درخواست‌های بیمه با ستون‌های: تاریخ مراجعه | پزشک | کلینیک | سرویس | مبلغ اصلی | پوشش بیمه (درصد + مبلغ) | سهم بیمار | سهم بیمه | وضعیت | تاریخ ارسال | تاریخ پرداخت | شماره پیگیری.
- `actions?(row)` → دکمه‌های انتقال وضعیت بر اساس `allowed_transitions`.
- کلیک روی ردیف → `Modal` (size `lg`) با توضیحات کامل + **لاگ کامل تغییرات** به‌صورت timeline.
- عملیات `reject` باید `reject_reason` بگیرد؛ `submit` باید `tracking_number` اختیاری بگیرد.
### ۶. Frontend — فیلتر، جستجو، مرتب‌سازی، URL sync
فیلترها در هر دو سطح: بیمار (فقط سطح ۱) | بیمه | پزشک | کلینیک | وضعیت Claim | بازه زمانی (`PersianDatePicker` + میان‌بر یک ماه/یک سال اخیر) | وضعیت پرداخت.
همه‌ی فیلترها + `page` + `sort`/`dir` باید در **query string آدرس** ذخیره شوند (`useSearchParams`) تا رفرش و اشتراک‌گذاری لینک کار کند. این رفع مشکل فعلی است.
مرتب‌سازی سرور-ساید از طریق `sort`/`dir` روی: نام بیمار، تعداد درخواست، مجموع خدمات، مجموع سهم بیمه، آخرین فعالیت.
## نکات مهم
- **الزامی:** صفحه جدید با تم، Layout و کامپوننت‌های موجود ساخته شود — بدون طراحی جدید. از `components/ui/` استفاده شود، نه جدول دست‌ساز.
- برای dropdownها **همیشه** `SearchableSelect`، هرگز `<select>` نیتیو.
- `StatusBadge` فعلاً `type` برای `claim` ندارد — نوع جدید `claim` با نگاشت pending=خاکستری، submitted=آبی، approved=کهربایی، rejected=قرمز، paid=سبز، mixed=بنفش اضافه شود و `STATUS_META` محلی حذف شود.
- استایل: از design tokenها (`var(--text-3)`, `.card`, `.btn primary sm`, `.badge`) استفاده شود. هگز hardcode ممنوع.
- پاسخ paginated: items از `data?.data`، total از `data?.meta?.totalRecords`. single: `data?.data` (گاهی double-nested).
- تاریخ‌ها Unix timestamp صحیح؛ تبدیل بازه با `toUnix` / `endOfDayUnix` مثل کد فعلی (`ClaimsPage.tsx:21-30`)؛ نمایش شمسی با `formatDate` / `formatDateTime`.
- مبالغ با `formatRial`؛ مرز ریال/تومان رعایت شود.
- همه controllerها از `BaseController`؛ پاسخ‌ها با `$this->paginated()` / `$this->success()` / `$this->error()`.
- انتقال وضعیت غیرمجاز باید سمت سرور هم reject شود (`Claim::TRANSITIONS` منبع حقیقت است) — اتکا به مخفی‌کردن دکمه در UI کافی نیست.
- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`.
- بعد از تغییر API، `clinicpro/docs/api/` به‌روز شود.
## تست پذیرش
۱. `/admin/claims` لیست بیماران با تجمیع درست نمایش می‌دهد؛ جمع ستون سهم بیمه با `insurance-debt` هم‌خوان است.
۲. کلیک روی بیمار → صفحه جزئیات با همه‌ی رکوردهای بیمه‌ی همان بیمار.
۳. انتقال وضعیت یک Claim (submit با شماره پیگیری، سپس approve، سپس pay) → لاگ در timeline ثبت می‌شود.
۴. reject بدون دلیل → خطا؛ با دلیل → ثبت و نمایش دلیل.
۵. فیلتر بازه زمانی + بیمه اعمال شود، صفحه رفرش شود → فیلترها از URL بازیابی می‌شوند.
۶. کاربر بدون فیچر `insurance` → صفحه در دسترس نیست (`FeatureGate`).
۷. مبالغ سهم بیمه/بیمار در این صفحه با صفحه پرداخت و مودال فاکتور **دقیقاً** یکی است.
@@ -0,0 +1,202 @@
# اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و 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 شود.