# اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و 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 شود.