- 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.
14 KiB
اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و 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 (منطق درست):
$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، نه از قرارداد:
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
$visitShare = (int) round($afterSupp);
assets/admin/components/session/PaymentStep.tsx:117-122 — کل محاسبه سمت کلاینت:
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 سهم بیمه را نادیده میگیرد:
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):
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):
baseInsuranceRialssupplementaryInsuranceRialspatientShareRials
قرارداد: patientShareRials همان چیزی است که finalPriceRials باید باشد (سهم بیمار قبل از تخفیف دستی). یعنی:
servicesTotalRials = مجموع مبلغ اصلی همه اقلام (ویزیت + سرویسها)
baseInsuranceRials + supplementaryInsuranceRials + patientShareRials = servicesTotalRials
finalPriceRials = patientShareRials
payable = finalPriceRials - discountRials
remaining = max(0, payable - paidTotal)
هر جا session ذخیره یا بازمحاسبه میشود این سه ستون هم نوشته شوند. migration لازم است:
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}/...) این فیلدها اضافه شوند:
{
"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 شود.