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

14 KiB
Raw Blame History

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

  • baseInsuranceRials
  • supplementaryInsuranceRials
  • patientShareRials

قرارداد: 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 شود.