From 4f4bce9fe23f87a27534b7f5818e98f4a1065327 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 29 Jul 2026 13:28:59 +0330 Subject: [PATCH] feat(migrations): update franchise to percentage in tenant_insurances and tenant_service_coverage - Changed franchise_rials to franchise_percent in tenant_insurances and tenant_service_coverage tables. - Reset old rial values to 0/NULL as they are not convertible to percentage. feat(command): add SeedInsuranceScenarioCommand for seeding insurance data - Implemented a command to seed supplementary insurance contracts, patients, and claims for a specified doctor. - Includes functionality for purging existing scenario data and generating new entries with predefined contracts and patient scenarios. --- ...-franchise-percent-and-claims-insurance.md | 542 ++++++++++++++++++ .../admin/components/InsuranceModal.test.tsx | 65 ++- assets/admin/components/InsuranceModal.tsx | 51 +- .../components/ServiceInsuranceModal.tsx | 37 +- .../TenantInsuranceContracts.test.tsx | 8 +- .../components/TenantInsuranceContracts.tsx | 17 +- .../ConfirmAppointmentModal.test.tsx | 2 +- .../appointments/TurnsTimeline.test.tsx | 2 +- .../components/session/CreateStep.test.tsx | 18 +- .../admin/components/session/CreateStep.tsx | 4 +- assets/admin/lib/insuranceShares.ts | 19 +- .../admin/pages/AppointmentEditPage.test.tsx | 2 +- assets/admin/pages/ClaimsPage.tsx | 46 +- assets/admin/pages/ServiceDetailPage.tsx | 4 +- assets/admin/types/index.ts | 8 + migrations/Version20260728180000.php | 44 ++ src/Billing/Controller/BillingController.php | 5 + src/Billing/Repository/ClaimRepository.php | 42 +- src/Billing/Service/BillingCalculator.php | 16 +- .../Command/SeedInsuranceScenarioCommand.php | 326 +++++++++++ .../Controller/InsuranceController.php | 12 +- src/Insurance/Entity/TenantInsurance.php | 11 +- .../Entity/TenantServiceCoverage.php | 11 +- .../Service/TenantInsuranceService.php | 97 +++- src/Insurance/ValueObject/CoverageRule.php | 9 +- .../NumericFieldNormalizerSubscriber.php | 2 +- tests/Billing/BillingCalculatorTest.php | 65 ++- tests/Billing/ClaimsByPatientTest.php | 74 ++- .../CoveragePercentResolutionTest.php | 13 +- ...TenantInsuranceCategoryCoverageApiTest.php | 43 +- .../TenantInsuranceContractApiTest.php | 39 +- 31 files changed, 1497 insertions(+), 137 deletions(-) create mode 100644 .claude/prompt/insurance-franchise-percent-and-claims-insurance.md create mode 100644 migrations/Version20260728180000.php create mode 100644 src/Insurance/Command/SeedInsuranceScenarioCommand.php diff --git a/.claude/prompt/insurance-franchise-percent-and-claims-insurance.md b/.claude/prompt/insurance-franchise-percent-and-claims-insurance.md new file mode 100644 index 00000000..4b4e3702 --- /dev/null +++ b/.claude/prompt/insurance-franchise-percent-and-claims-insurance.md @@ -0,0 +1,542 @@ +# بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات + +## پروژه + +`clinicpro` (بک‌اند Symfony + پنل ادمین React). تغییرات فقط داخل همین ریپو است؛ کلاینت بیرونی ندارد +(`nobat724_front` و `clinic-pro-tauri` هیچ‌کدام `/api/v1/billing/tenant-insurances` یا +`/api/v1/billing/claims/*` را مصرف نمی‌کنند — قبل از شروع با grep تأیید کن). + +## زمینه + +مدل بیمهٔ tenant امروز این است: + +- کاتالوگ بیمه: `insurances` (`type` = `basic` | `supplementary`) — ۶ بیمهٔ پایه و ۱۰ بیمهٔ تکمیلی seed شده‌اند. +- قرارداد tenant: `TenantInsurance` (`tenant_insurances`) با `coverage_percent`، `franchise_rials`، + `annual_ceiling_rials`، `kind`. +- درصد پوشش به تفکیک نوع خدمت: `TenantInsuranceCategoryCoverage` (override قرارداد) → + `InsuranceCoverageDefault` (پیش‌فرض مرکزی ادمین) → `coverage_percent` قرارداد (fallback قدیمی). +- نوع خدمت: enum `ServiceCategory` = `outpatient` (خدمات سرپایی) / `inpatient` (خدمات بستری). +- «کدام نوع خدمت اصلاً بیمه‌ای است» یک سوییچ سراسریِ tenant است: `TenantServiceCategorySetting` + (`tenant_service_category_settings`, ردیف نداشته = فعال) و از `GET /api/v1/insurance-pricing` + در کلید `service_categories` بیرون می‌آید. +- محاسبهٔ سهم: `BillingCalculator::calculateItem()` روی `CoverageRule` — فرانشیز فقط در قرارداد تکمیلی + اثر دارد و به‌صورت **مبلغ ریالی** به سهم بیمار اضافه می‌شود. +- مطالبات: `claims` با `insurance_id` و `insurance_kind` (`base` | `supplementary` — دقت کن، واژگان + مطالبه `base` است نه `basic`). داشبورد سطح‌اول `ClaimsPage` یک ردیف به‌ازای هر **بیمار** می‌دهد. + +سه ایراد واقعی در همین مسیر وجود دارد: + +1. مودال «افزودن بیمه» ورودی «درصد پوشش» را برای **هر دو** نوع خدمت رندر می‌کند، بی‌توجه به این‌که + tenant کدام نوع را فعال کرده، و خالی‌ماندنِ آن‌ها هیچ خطایی نمی‌دهد → قرارداد با پوشش صفر ثبت می‌شود. +2. فرانشیز به‌صورت «تومان» گرفته و ذخیره می‌شود (`franchise_rials`) در حالی که فرانشیز در بیمهٔ تکمیلی + یک **درصد** است. +3. در داشبورد مطالبات (سطح اول) هیچ‌جا معلوم نیست هر ردیف مربوط به کدام بیمه است، و نه فیلتر «نوع بیمه» + دارد و نه جستجو روی نام بیمه کار می‌کند. (سطح دومِ `ClaimPatientDetailPage` نام و نوع بیمه را دارد.) + +## مشکل / هدف + +۱. **درصد پوشش فقط برای نوع خدمتِ فعال، و الزامی.** در مودال افزودن/ویرایش بیمه، به‌جای «همهٔ + ServiceCategoryها»، فقط نوع‌های خدمتی رندر شوند که برای همان tenant فعال‌اند + (`service_categories[].enabled === true` از `GET /api/v1/insurance-pricing`)، و ثبت فرم بدون + درصدِ معتبر (۱ تا ۱۰۰) برای هر نوع خدمتِ رندرشده مجاز نباشد. بک‌اند هم همین را اجبار کند + (نه فقط UI). + +۲. **فرانشیز درصد است، نه تومان.** `franchise_rials` → `franchise_percent` در هر دو سطح + (قرارداد `TenantInsurance` و override خدمت `TenantServiceCoverage`)، در `CoverageRule`، + در `BillingCalculator`، در API، در پنل، و در آینهٔ سمت‌کلاینت `assets/admin/lib/insuranceShares.ts`. + + **معنای انتخاب‌شده (این را در کد کامنت کن):** فرانشیز درصدی است از مبلغی که بیمهٔ تکمیلی روی آن + کار می‌کند — یعنی «باقیماندهٔ بعد از بیمهٔ پایه» — و سهم بیمار است: + + ``` + remainingAfterBase = total − baseShare(سقف‌خورده) + suppShare = remainingAfterBase × coveragePercent٪ (سقف‌خورده) + franchiseAmount = remainingAfterBase × franchisePercent٪ ← جدید + patient = min(total, remainingAfterBase − suppShare + franchiseAmount) + ``` + + این با واقعیت بازار می‌خواند («تکمیلی ۹۰٪ می‌دهد، فرانشیز ۱۰٪») و ساختار فعلی فرمول را حفظ می‌کند؛ + تنها تفاوت این است که مبلغ فرانشیز به‌جای عدد ثابت، از درصد ساخته می‌شود. فرانشیز در بیمهٔ **پایه** + همچنان بی‌اثر است. + +۳. **مطالبات: کدام بیمه.** در `ClaimsPage` (سطح اول) ستون «بیمه» اضافه شود (یک بیمار می‌تواند مطالبه + زیر چند بیمه داشته باشد → لیست بیمه‌های آن بیمار با نوعشان)، فیلتر «نوع بیمه» (پایه/تکمیلی) اضافه + شود، و جستجوی متنی علاوه بر نام/موبایل/کد ملی بیمار، **نام بیمه** را هم پوشش دهد. + +۴. **سناریوی واقعی + تست.** برای پزشک `09389388131` (کاربر `users.id = 11685`، پزشک + `doctors.id = 11548`، `entity_type='doctor'`, `entity_id=11548`) چند بیمهٔ تکمیلی و چند بیمار + ساخته شود که از این بیمه‌ها استفاده می‌کنند، مطالبات تولید و مسیر وضعیت‌ها تست شود. + +## معیار پذیرش + +**درصد پوشش / نوع خدمت فعال** + +- ✅ موفق: tenantـی که فقط `outpatient` را فعال دارد، مودال «افزودن بیمه» تنها یک ورودی + «درصد پوشش — خدمات سرپایی» نشان می‌دهد؛ با مقدار ۷۰ ثبت می‌شود و + `GET /api/v1/billing/tenant-insurances` برای آن قرارداد `category_coverages.outpatient = 70` می‌دهد. +- ❌ خطا: `POST /api/v1/billing/tenant-insurances` با + `category_coverages: [{key:'outpatient', coverage_percent: null}]` در حالی که `outpatient` فعال است + → HTTP 422 با `{ success:false, errors:[{ code:'ERR_VALIDATION_001', field:'category_coverages', message:'درصد پوشش خدمات سرپایی الزامی است' }] }`. + دکمهٔ «ثبت بیمه» هم در UI تا پرشدن همهٔ درصدهای فعال غیرفعال است. +- ⚠️ مرزی: نوع خدمتی که در تنظیمات tenant **غیرفعال** است نه رندر می‌شود و نه در payload می‌رود؛ + قراردادی که از قبل برای آن نوع override داشت، آن override دست‌نخورده در DB می‌ماند + (ارسال‌نشدن ≠ حذف؛ حذف فقط با `coverage_percent: null` صریح انجام می‌شود). + +**فرانشیز درصدی** + +- ✅ موفق: قرارداد تکمیلی با `coverage_percent = 90` و `franchise_percent = 10` روی خدمتی + ۱٬۰۰۰٬۰۰۰ ریالی بدون بیمهٔ پایه → `supplementary = 900,000`، `patient = 100,000 + 100,000 = 200,000`، + و `total = base + supp + patient` همچنان برقرار است (تست موجود + `tests/Patient/SessionInsuranceShareTest::testTheBreakdownAlwaysSumsBackToTheGrossTotal` باید سبز بماند). +- ❌ خطا: `franchise_percent: 150` → HTTP 422 «فرانشیز باید بین ۰ تا ۱۰۰ باشد» + (`field: 'franchise_percent'`). همچنین `franchise_percent` منفی → همان خطا. +- ⚠️ مرزی: `franchise_percent = 100` روی قرارداد تکمیلی → سهم بیمار از `total` بیشتر نمی‌شود + (`min(total)` نگه‌دارنده است) و سهم بیمه منفی نمی‌شود؛ در بیمهٔ **پایه** هر مقدار فرانشیز بی‌اثر است. + +**مطالبات** + +- ✅ موفق: `GET /api/v1/billing/claims/by-patient` برای بیماری با دو مطالبه زیر دو بیمه، آرایهٔ + `insurances: [{insurance_id, insurance_name, kind}, …]` می‌دهد و ستون «بیمه» در جدول هر دو را + نشان می‌دهد. +- ❌ خطا: `?kind=bogus` → HTTP 422 «نوع بیمه نامعتبر است» (نه ۵۰۰ و نه لیست خالیِ بی‌صدا). +- ⚠️ مرزی: `?search=آسیا` فقط بیمارانی را برمی‌گرداند که مطالبه‌ای زیر «بیمه آسیا» دارند، و + `meta.totalRecords` با همان فیلتر هم‌خوان است (شمارش و لیست از یک WHERE می‌آیند)؛ + `?kind=supplementary&insurance_id=<یک بیمهٔ پایه>` → صفر ردیف، بدون خطا. + +**سناریو** + +- ✅ موفق: بعد از اجرای دستور سناریو، ورود به پنل با `09389388131` → صفحهٔ «پرونده‌های بیمه» + حداقل ۴ بیمار با مطالبات زیر ۳ بیمهٔ تکمیلی مختلف نشان می‌دهد؛ کارت‌های آمار (ادعا/وصول/مانده) + با جمع ستون‌های جدول هم‌خوان‌اند. +- ⚠️ مرزی: اجرای دوبارهٔ دستور، داده را دوباره نمی‌سازد (idempotent) یا با `--purge` تمیز و بازسازی می‌کند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Insurance/Entity/TenantInsurance.php` | ستون `franchise_rials` → `franchise_percent` | +| `clinicpro/src/Insurance/Entity/TenantServiceCoverage.php` | همان تغییر در override سطح خدمت | +| `clinicpro/src/Insurance/ValueObject/CoverageRule.php` | `franchiseRials` → `franchisePercent` | +| `clinicpro/src/Insurance/Service/TenantInsuranceService.php` | `activate()`، `buildRule()`، `setServiceCoverage()`، اعتبارسنجی درصد | +| `clinicpro/src/Insurance/Controller/InsuranceController.php` | خواندن/نوشتن `franchise_percent`، اجبار درصد برای نوع خدمت فعال (خطوط ۴۷۶–۵۹۲ و ۶۴۷–۶۹۳) | +| `clinicpro/src/Billing/Service/BillingCalculator.php` | فرمول فرانشیز درصدی | +| `clinicpro/src/Billing/Repository/ClaimRepository.php` | `patientAggregateSql()` + `patientAggregateFilters()` — بیمه‌ها، فیلتر `kind`، جستجوی نام بیمه | +| `clinicpro/src/Billing/Controller/BillingController.php` | `claimsByPatient()`، `claimFilters()` — پارامتر `kind` و نگاشت نام بیمه | +| `clinicpro/assets/admin/components/InsuranceModal.tsx` | ورودی فرانشیز درصدی + درصد الزامی + فقط نوع خدمت فعال | +| `clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` | تغذیهٔ مودال با نوع خدمتِ فعال، خلاصه و جزئیات فرانشیز درصدی | +| `clinicpro/assets/admin/lib/insuranceShares.ts` | آینهٔ فرمول سمت کلاینت | +| `clinicpro/assets/admin/components/session/CreateStep.tsx` | مصرف‌کنندهٔ `franchise_rials` در پذیرش | +| `clinicpro/assets/admin/components/ServiceInsuranceModal.tsx`، `pages/ServiceDetailPage.tsx` | override فرانشیز سطح خدمت | +| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | ستون بیمه + فیلتر نوع بیمه | +| `clinicpro/assets/admin/types/index.ts` | `ClaimPatientRow` | +| `clinicpro/migrations/` | migration تغییر نام و نوع ستون‌ها | +| `clinicpro/docs/api/insurance.md`، `docs/api/billing.md` | مستندسازی قرارداد جدید | + +## وضعیت فعلی + +`clinicpro/src/Billing/Service/BillingCalculator.php` (خطوط ۳۱–۴۳) — فرانشیز مبلغ ثابت: + +```php + $suppShare = Money::zero(); + if ($supplementary !== null && $supplementary->covered) { + $suppShare = $remaining->percent($supplementary->coveragePercent); + if ($supplementary->ceilingRials !== null) { + $suppShare = $suppShare->min(new Money($supplementary->ceilingRials)); + } + $remaining = $remaining->sub($suppShare); + } + + // بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ + // تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمی‌کند. + $franchise = new Money($supplementary?->franchiseRials ?? 0); + $patient = $remaining->add($franchise)->min($total); +``` + +`clinicpro/assets/admin/components/InsuranceModal.tsx` (خطوط ۲۱۹–۲۵۵) — همهٔ categoryها رندر می‌شوند، +هیچ اجباری روی مقدار نیست، و فرانشیز تومانی است: + +```tsx +
+ {categories.map((c) => ( +
+ + setPercent(c.key, e.target.value)} /> + … + {!isBasic && ( +
+ + set({ franchise: digitsOnly(e.target.value) })} /> +
+ )} +``` + +و در `buildInsurancePayload()`: + +```ts + franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0), +``` + +`clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` — مودال از لیست سراسری تغذیه می‌شود +و فرانشیز ریالی نمایش داده می‌شود: + +```tsx + const { categories } = useServiceCategories(); // همهٔ نوع‌ها، نه نوع‌های فعالِ tenant + … + if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) { + parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`); + } +``` + +`clinicpro/src/Billing/Repository/ClaimRepository.php` — نه بیمه‌ای در SELECT سطح بیمار هست و نه +جستجو/فیلتر نوع بیمه: + +```php + if (!empty($filters['search'])) { + $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)'; + $params['search'] = '%' . trim((string) $filters['search']) . '%'; + } +``` + +## وظایف + +### ۱. مهاجرت داده: `franchise_rials` → `franchise_percent` + +`TenantInsurance`: + +```php + #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)] + private string $franchisePercent = '0.00'; + + public function getFranchisePercent(): float { return (float) $this->franchisePercent; } + public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; } +``` + +`TenantServiceCoverage`: همان تغییر با `nullable: true` و getter/setter `?float`. +هر دو `toArray()` کلید `franchise_percent` بدهند (کلید `franchise_rials` کاملاً حذف شود — سازگاری +عقب‌رو لازم نیست چون تنها مصرف‌کننده پنل خودِ همین ریپوست). + +Migration دستی بنویس (نه فقط `diff`)، چون تبدیل ریال به درصد معنا ندارد: + +```php +$this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent DECIMAL(5,2) NOT NULL DEFAULT 0'); +$this->addSql('UPDATE tenant_insurances SET franchise_percent = 0'); +$this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent DECIMAL(5,2) DEFAULT NULL'); +$this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL'); +``` + +در `getDescription()` صریح بنویس که مقادیر ریالیِ قبلی قابل تبدیل نیستند و صفر می‌شوند. + +**نحوه تست:** `ddev exec php bin/console doctrine:migrations:migrate --no-interaction` سپس +`ddev exec php bin/console doctrine:schema:validate` باید mapping را سبز بدهد. + +### ۲. فرمول فرانشیز درصدی + +`CoverageRule`: + +```php +final readonly class CoverageRule +{ + public function __construct( + public float $coveragePercent, + /** درصدِ سهم بیمار از «باقیماندهٔ بعد از بیمهٔ پایه»؛ فقط در قرارداد تکمیلی معنا دارد. */ + public float $franchisePercent, + public ?int $ceilingRials, + public bool $covered = true, + ) {} + + public static function notCovered(): self { return new self(0.0, 0.0, null, false); } +} +``` + +`BillingCalculator::calculateItem()` — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته می‌شود: + +```php + $suppBase = $remaining; // باقیماندهٔ بعد از بیمهٔ پایه + $suppShare = Money::zero(); + if ($supplementary !== null && $supplementary->covered) { + $suppShare = $suppBase->percent($supplementary->coveragePercent); + if ($supplementary->ceilingRials !== null) { + $suppShare = $suppShare->min(new Money($supplementary->ceilingRials)); + } + $remaining = $suppBase->sub($suppShare); + } + + // فرانشیز درصدی است، نه مبلغ: سهمِ بیمار از همان مبلغی که تکمیلی رویش کار می‌کند. + $franchise = $supplementary !== null && $supplementary->covered + ? $suppBase->percent($supplementary->franchisePercent) + : Money::zero(); + $patient = $remaining->add($franchise)->min($total); +``` + +`TenantInsuranceService::buildRule()` و `activate()` و `setServiceCoverage()` را به +`franchisePercent`/`franchise_percent` ببر و اعتبارسنجی ۰..۱۰۰ را (همان الگوی +`setCategoryCoverages()`) اضافه کن: + +```php + if ($franchisePercent < 0 || $franchisePercent > 100) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent'); + } +``` + +`InsuranceController` هر سه نقطهٔ `franchise_rials` (خطوط ۵۰۲، ۵۶۴–۵۶۵، ۶۸۵) را با +`franchise_percent` و cast به `float` جایگزین کند. + +**نحوه تست:** یک تست واحد در `tests/Billing/` برای `BillingCalculator` با سه سناریو: +(الف) پایه ۳۰٪ + تکمیلی ۹۰٪ + فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ ریال، (ب) فرانشیز ۱۰۰٪، +(ج) بیمهٔ پایه با فرانشیز غیرصفر (باید بی‌اثر بماند). در هر سه، +`total === base + supp + patient` را assert کن. علاوه بر آن: +`ddev exec php bin/phpunit tests/Patient/SessionInsuranceShareTest.php` باید سبز بماند +(مقادیر انتظاری‌اش را طبق فرمول جدید به‌روز کن، نه با تغییر فرمول). + +### ۳. الزامی‌کردن درصد پوشش برای نوع خدمتِ فعال + +**بک‌اند** — در `InsuranceController::applyCategoryCoverages()` قبل از فراخوانی سرویس، بررسی کن که +برای هر نوع خدمتِ فعالِ همان tenant یک درصد معتبر آمده باشد: + +```php + $enabled = $this->serviceCategories->enabledKeys($entityType, $entityId); + $sent = []; + foreach ($data['category_coverages'] ?? [] as $row) { + $sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null; + } + foreach ($enabled as $key) { + if (($sent[$key] ?? null) === null || $sent[$key] === '' || (float) $sent[$key] <= 0) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()), + 422, + 'category_coverages', + ); + } + } +``` + +توجه: `applyCategoryCoverages()` امروز `$entityType/$entityId` ندارد — امضایش را گسترش بده +(هر دو فراخوان `activate` و `update` این مقادیر را در دست دارند). این چک فقط وقتی اجرا شود که +کلید `category_coverages` در payload آمده باشد؛ PATCHـهای دیگر (مثل toggle وضعیت) نباید بشکنند. + +**فرانت** — `TenantInsuranceContracts.tsx` به‌جای `useServiceCategories()` نوع خدمتِ **فعالِ tenant** +را از همان `pricingQuery` که از قبل fetch می‌شود بگیرد (بدون درخواست جدید): + +```tsx + const enabledCategories: ServiceCategoryOption[] = useMemo(() => ( + ((pricingQuery.data as any)?.data?.service_categories ?? []) + .filter((c: { enabled: boolean }) => c.enabled) + .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label })) + ), [pricingQuery.data]); +``` + +و همین را به `categories` مودال بدهد. `useServiceCategories` اگر مصرف‌کنندهٔ دیگری ندارد دست‌نخورده +بماند (فرم خدمت از آن استفاده می‌کند — قبل از حذف grep کن). + +`InsuranceModal.tsx`: دکمهٔ ثبت تا وقتی هر نوع خدمتِ رندرشده مقدار ۱..۱۰۰ نداشته باشد `disabled` بماند، +و زیر ورودیِ خالی پیام «الزامی است» با `var(--danger)` نشان داده شود: + +```tsx + const percentsValid = categories.every((c) => { + const n = Number(form.categoryPercents[c.key]); + return Number.isFinite(n) && n > 0 && n <= 100; + }); + … + disabled={!form.insuranceId || !percentsValid || isPending} +``` + +(وقتی `canUpdate === false` درصدها فقط‌خواندنی و ارسال‌نشدنی‌اند — در آن حالت این اجبار اعمال نشود.) + +**نحوه تست:** +- API: با توکن پزشک `09389388131`، `PUT /api/v1/insurance-pricing` با + `service_categories: [{key:'outpatient',enabled:true},{key:'inpatient',enabled:false}]`، سپس + `POST /api/v1/billing/tenant-insurances` یک‌بار بدون درصد (انتظار ۴۲۲) و یک‌بار با + `[{key:'outpatient',coverage_percent:70}]` (انتظار ۲۰۱). +- UI: `assets/admin/components/InsuranceModal.test.tsx` را با کیس «ثبت غیرفعال است تا درصد پر شود» + و «فقط نوع خدمتِ فعال رندر می‌شود» گسترش بده؛ `yarn test`. + +### ۴. فرانشیز درصدی در پنل + +- `InsuranceModal.tsx`: برچسب «فرانشیز (درصد)»، ورودی `digitsOnly(value, 3)` با اعتبارسنجی ≤۱۰۰، + `contractToForm()` از `c.franchise_percent` بخواند (بدون `rialToToman`)، و + `buildInsurancePayload()` بفرستد: + + ```ts + franchise_percent: isBasic ? 0 : Number(v.franchise) || 0, + ``` + + `Contract` و `InsuranceFormValues` هم به‌روز شوند (`franchise_percent: number`). +- `TenantInsuranceContracts.tsx`: `contractSummary()` → ``فرانشیز ${formatNumber(c.franchise_percent)}٪`` + و `ContractDetails` → مقدار `٪`دار. +- `insuranceShares.ts`: `CoverageRule.franchise` به معنی درصد شود و `patientShareOf()` دقیقاً آینهٔ + فرمول جدید سرور باشد (پایهٔ فرانشیز = باقیماندهٔ بعد از پایه). این فایل تنها منبع محاسبه در پنل است؛ + هیچ صفحه‌ای فرمول موازی ننویسد. +- `CreateStep.tsx`، `ServiceInsuranceModal.tsx`، `ServiceDetailPage.tsx`: `franchise_rials` → + `franchise_percent`؛ در `ServiceInsuranceModal` ورودی از `PriceInput` به ورودی درصد تغییر کند و + در `ServiceDetailPage` نمایش `فرانشیز {formatRial(...)}` به `فرانشیز {formatNumber(...)}٪` تبدیل شود. + +**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` (هیچ ارجاع باقیمانده‌ای به `franchise_rials` +نباید بماند — با grep هم تأیید کن) و `yarn test` (فایل‌های تستِ متأثر: +`InsuranceModal.test.tsx`, `TenantInsuranceContracts.test.tsx`, `session/CreateStep.test.tsx`, +`appointments/ConfirmAppointmentModal.test.tsx`, `appointments/TurnsTimeline.test.tsx`, +`pages/AppointmentEditPage.test.tsx`). + +### ۵. بیمه در داشبورد مطالبات + +**Repository** — در `patientAggregateSql()` و `detailsForPatient()` هر دو، join کاتالوگ اضافه شود +(برای جستجوی نام بیمه) و در نمای تجمیعی، بیمه‌های هر بیمار جمع شوند: + +```sql + LEFT JOIN insurances ins ON ins.id = c.insurance_id + … + GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, ins.name, c.insurance_kind) SEPARATOR '~') AS insurances, +``` + +سپس در `aggregateByPatient()` به آرایهٔ ساخت‌یافته تبدیل شود: + +```php + 'insurances' => array_values(array_filter(array_map(static function (string $chunk): ?array { + [$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null); + return $id === null || $id === '' ? null : [ + 'insurance_id' => (int) $id, + 'insurance_name' => $name, + 'kind' => $kind, // base | supplementary + ]; + }, explode('~', (string) $r['insurances'])))), +``` + +در `patientAggregateFilters()`: + +```php + if (!empty($filters['kind'])) { + $conditions[] = 'c.insurance_kind = :kind'; + $params['kind'] = $filters['kind']; + } + if (!empty($filters['search'])) { + $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search' + . ' OR u.national_code LIKE :search OR ins.name LIKE :search)'; + $params['search'] = '%' . trim((string) $filters['search']) . '%'; + } +``` + +⚠️ همان WHERE در `countPatientsWithClaims()` و `detailsForPatient()` هم استفاده می‌شود؛ join جدید باید +در **هر سه** SQL باشد وگرنه شمارش با لیست واگرا می‌شود یا کوئری با «Unknown column ins.name» می‌شکند. +شرط محیطِ `c.entity_type = :type AND c.entity_id = :id` را دست نزن (کامنت هشدارِ همان فایل). + +**Controller** — `claimFilters()` کلید `kind` را بگیرد و مقدارش را اعتبارسنجی کند +(`base` | `supplementary`، در غیر این صورت `ERR_VALIDATION_001` با ۴۲۲ و پیام «نوع بیمه نامعتبر است»). + +**فرانت** — `ClaimsPage.tsx`: + +```tsx +const KIND_OPTIONS = [ + { value: '', label: 'همه انواع' }, + { value: 'base', label: 'پایه' }, + { value: 'supplementary', label: 'تکمیلی' }, +]; +``` + +- فیلتر نوع بیمه با `SearchableSelect` (طبق قاعدهٔ پروژه؛ ` set({ franchise: digitsOnly(e.target.value) })} /> + + set({ franchise: digitsOnly(e.target.value, 3) })} + /> + + {franchiseInvalid ? 'فرانشیز نمی‌تواند بیش از ۱۰۰ باشد' : 'سهم بیمار از مبلغ تحت پوشش'} +
)}
diff --git a/assets/admin/components/ServiceInsuranceModal.tsx b/assets/admin/components/ServiceInsuranceModal.tsx index 35b4119a..9430a3a1 100644 --- a/assets/admin/components/ServiceInsuranceModal.tsx +++ b/assets/admin/components/ServiceInsuranceModal.tsx @@ -19,14 +19,14 @@ interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; - franchise_rials: number | null; + franchise_percent: number | null; ceiling_rials: number | null; } interface Draft { covered: boolean; coverage_percent: number | null; - franchise_rials: number | null; + franchise_percent: number | null; ceiling_rials: number | null; } @@ -46,20 +46,22 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser const rows = (data as any)?.data?.data as CoverageRow[] | undefined; const existing = rows?.find((r) => r.service_item_uuid === item.uuid); - const [draft, setDraft] = useState({ covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null }); - // متن خام فیلد درصد جدا از مقدار عددی نگه داشته می‌شود تا کاربر بتواند فیلد را خالی کند. + const [draft, setDraft] = useState({ covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null }); + // متن خام فیلدهای درصد جدا از مقدار عددی نگه داشته می‌شود تا کاربر بتواند فیلد را خالی کند. const [percentText, setPercentText] = useState(''); + const [franchiseText, setFranchiseText] = useState(''); useEffect(() => { setDraft(existing ? { covered: existing.covered, coverage_percent: existing.coverage_percent, - franchise_rials: existing.franchise_rials, + franchise_percent: existing.franchise_percent, ceiling_rials: existing.ceiling_rials, } - : { covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null }); + : { covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null }); setPercentText(existing?.coverage_percent == null ? '' : String(existing.coverage_percent)); + setFranchiseText(existing?.franchise_percent == null ? '' : String(existing.franchise_percent)); }, [existing]); const saveMut = useMutation({ @@ -68,7 +70,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser service_item_uuid: item.uuid, covered: draft.covered, coverage_percent: draft.coverage_percent, - franchise_rials: draft.franchise_rials, + franchise_percent: draft.franchise_percent, ceiling_rials: draft.ceiling_rials, }), onSuccess: () => { @@ -144,14 +146,19 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser />
- - setDraft((d) => ({ ...d, franchise_rials: v || null }))} - placeholder="۰" - min={0} + + { + const digits = digitsOnly(e.target.value, 3); + setFranchiseText(digits); + setDraft((d) => ({ ...d, franchise_percent: parseUserNumberClamped(digits, 0, 100) })); + }} + onBlur={() => setFranchiseText(draft.franchise_percent == null ? '' : String(draft.franchise_percent))} />
diff --git a/assets/admin/components/TenantInsuranceContracts.test.tsx b/assets/admin/components/TenantInsuranceContracts.test.tsx index e1e9883f..f995126a 100644 --- a/assets/admin/components/TenantInsuranceContracts.test.tsx +++ b/assets/admin/components/TenantInsuranceContracts.test.tsx @@ -17,7 +17,7 @@ const patch = api.patch as ReturnType; const mk = (over: Partial): Contract => ({ uuid: 'u', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', - version: 1, is_active: true, coverage_percent: 70, franchise_rials: 0, + version: 1, is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, kind: 'basic', effective_from: 0, effective_to: null, ...over, }); @@ -55,15 +55,15 @@ describe('contractSummary', () => { }); it('shows the franchise only on a supplementary contract', () => { - const basic = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'basic' }), categories); + const basic = contractSummary(mk({ franchise_percent: 20, insurance_kind: 'basic' }), categories); expect(basic).not.toContain('فرانشیز'); - const supp = contractSummary(mk({ franchise_rials: 500_000, insurance_kind: 'supplementary' }), categories); + const supp = contractSummary(mk({ franchise_percent: 20, insurance_kind: 'supplementary' }), categories); expect(supp).toContain('فرانشیز'); }); it('marks an unlimited ceiling', () => { - const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }), categories); + const s = contractSummary(mk({ franchise_percent: 0, annual_ceiling_rials: null }), categories); expect(s).toContain('سقف پوشش نامحدود'); }); diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx index d233a2e3..989d79eb 100644 --- a/assets/admin/components/TenantInsuranceContracts.tsx +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -46,8 +46,8 @@ export function contractSummary(c: Contract, categories: ServiceCategoryOption[] .map((cat) => `${cat.label} ${formatNumber(percents[cat.key])}٪`); const parts = labelled.length > 0 ? labelled : [`پوشش ${formatNumber(c.coverage_percent)}٪`]; - if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) { - parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`); + if (c.insurance_kind === 'supplementary' && c.franchise_percent > 0) { + parts.push(`فرانشیز ${formatNumber(c.franchise_percent)}٪`); } parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود'); @@ -112,6 +112,15 @@ export default function TenantInsuranceContracts() { const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? []; const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? []; + + // فرم قرارداد فقط نوع خدمت‌هایی را می‌گیرد که همین tenant بیمه‌ای‌شان می‌کند؛ + // لیست سراسری service-categories اینجا اشتباه است چون نوع خاموش را هم می‌آورد. + const enabledCategories: ServiceCategoryOption[] = useMemo( + () => ((pricingQuery.data as any)?.data?.service_categories ?? []) + .filter((c: { enabled: boolean }) => c.enabled) + .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label })), + [pricingQuery.data], + ); const activeIds = new Set(contracts.map((c) => c.insurance_id)); // Add-mode options: only the active tab's kind, excluding already-contracted insurances. const available = allInsurances.filter((i) => i.type === tab).filter((i) => !activeIds.has(i.insurance_id)); @@ -259,7 +268,7 @@ export default function TenantInsuranceContracts() { open={modalOpen} editContract={editContract} options={editContract ? allInsurances : available} - categories={categories} + categories={enabledCategories} kind={editContract ? kindOf(editContract) : tab} doctorUuid={doctorUuid} canUpdate={canUpdate} @@ -338,7 +347,7 @@ function ContractDetails({ contract: c, categories }: { contract: Contract; cate /> ))} {isSupplementary && ( - 0 ? formatRial(c.franchise_rials) : '—'} /> + 0 ? `${formatNumber(c.franchise_percent)}٪` : '—'} /> )} diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx index 64d0f90d..d906b1d6 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx @@ -120,7 +120,7 @@ describe('ConfirmAppointmentModal', () => { const CONTRACT = { uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', - is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null, + is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, category_coverages: { outpatient: 70, inpatient: 30 }, }; diff --git a/assets/admin/components/appointments/TurnsTimeline.test.tsx b/assets/admin/components/appointments/TurnsTimeline.test.tsx index 5684f85a..881b5126 100644 --- a/assets/admin/components/appointments/TurnsTimeline.test.tsx +++ b/assets/admin/components/appointments/TurnsTimeline.test.tsx @@ -75,7 +75,7 @@ describe('TurnsTimeline', () => { url === '/api/v1/billing/tenant-insurances' ? Promise.resolve({ success: true, data: { data: [{ insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true, - coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null, + coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, }] } }) : Promise.resolve({ success: true, data: [] }), ); diff --git a/assets/admin/components/session/CreateStep.test.tsx b/assets/admin/components/session/CreateStep.test.tsx index 07a94104..1e39b800 100644 --- a/assets/admin/components/session/CreateStep.test.tsx +++ b/assets/admin/components/session/CreateStep.test.tsx @@ -89,17 +89,23 @@ describe('patientShareOf — آینه‌ی BillingCalculator', () => { }); it('فرانشیز بیمهٔ پایه سهم بیمار را زیاد نمی‌کند', () => { - const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50_000, ceiling: null }, null); + const share = patientShareOf(600_000, { covered: true, percent: 100, franchise: 50, ceiling: null }, null); expect(share).toBe(0); }); - it('فرانشیز بیمهٔ تکمیلی به سهم بیمار اضافه می‌شود', () => { + it('فرانشیز درصدیِ تکمیلی از سهم بیمه کم می‌شود', () => { + // پایه ۷۰٪ → ۴۲۰٬۰۰۰؛ باقیمانده ۱۸۰٬۰۰۰؛ تکمیلی ۱۰۰٪ منهای فرانشیز ۱۰٪ → ۱۶۲٬۰۰۰. const share = patientShareOf( 600_000, - { covered: true, percent: 70, franchise: 90_000, ceiling: null }, - { covered: true, percent: 100, franchise: 50_000, ceiling: null }, + { covered: true, percent: 70, franchise: 50, ceiling: null }, + { covered: true, percent: 100, franchise: 10, ceiling: null }, ); - expect(share).toBe(50_000); + expect(share).toBe(18_000); + }); + + it('فرانشیز بزرگ‌تر از تعهد، سهم بیمه را صفر می‌کند نه منفی', () => { + const share = patientShareOf(600_000, null, { covered: true, percent: 20, franchise: 80, ceiling: null }); + expect(share).toBe(600_000); }); it('سقف تعهد سهم بیمه را محدود می‌کند', () => { @@ -117,7 +123,7 @@ describe('patientShareOf — آینه‌ی BillingCalculator', () => { const BASIC_CONTRACT = { uuid: 'c1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', - is_active: true, coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null, + is_active: true, coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, category_coverages: { outpatient: 70, inpatient: 30 }, }; diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index aa8274c4..d1020623 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -17,7 +17,7 @@ import { } from '../../lib/insuranceShares'; interface Contract extends TenantContract { uuid: string } -interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null } +interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_percent: number | null; ceiling_rials: number | null } interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string } interface PackageRow { uuid: string; title: string; total: number; available: boolean } interface StaffRow { uuid: string; full_name: string; active?: boolean } @@ -208,7 +208,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e return { covered: true, percent: ov?.coverage_percent ?? contractPercentFor(contract, category), - franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0, + franchise: isSupplementary ? (ov?.franchise_percent ?? contract.franchise_percent) : 0, ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials, }; }; diff --git a/assets/admin/lib/insuranceShares.ts b/assets/admin/lib/insuranceShares.ts index f21ddaea..e50b0dca 100644 --- a/assets/admin/lib/insuranceShares.ts +++ b/assets/admin/lib/insuranceShares.ts @@ -7,7 +7,7 @@ export interface CoverageRule { covered: boolean; percent: number; - /** فقط در بیمهٔ تکمیلی معنا دارد. */ + /** درصد سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در بیمهٔ تکمیلی معنا دارد. */ franchise: number; ceiling: number | null; } @@ -19,7 +19,7 @@ export interface TenantContract { insurance_kind: string | null; is_active?: boolean; coverage_percent: number; - franchise_rials: number; + franchise_percent: number; annual_ceiling_rials: number | null; category_coverages?: Record; } @@ -28,8 +28,9 @@ export interface TenantContract { export const DEFAULT_SERVICE_CATEGORY = 'outpatient'; /** - * سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی (روی باقیمانده) + فرانشیزِ تکمیلی. - * فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند. + * سهم بیمار یک ردیف: کل − سهم پایه (با سقف) − سهم تکمیلی. + * فرانشیزِ تکمیلی درصدی از همان مبلغِ تحت پوشش است و از سهم تکمیلی کسر می‌شود، + * نه اینکه روی سهم بیمار سوار شود؛ فرانشیزِ بیمهٔ پایه در محاسبه دخالت نمی‌کند. */ export function patientShareOf(total: number, base: CoverageRule | null, supp: CoverageRule | null): number { let baseShare = 0; @@ -41,12 +42,14 @@ export function patientShareOf(total: number, base: CoverageRule | null, supp: C } let suppShare = 0; if (supp && supp.covered) { - suppShare = Math.round(remaining * (supp.percent / 100)); + suppShare = Math.max( + 0, + Math.round(remaining * (supp.percent / 100)) - Math.round(remaining * (supp.franchise / 100)), + ); if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling); - remaining = remaining - suppShare; } - return Math.min(remaining + (supp?.franchise ?? 0), total); + return total - baseShare - suppShare; } /** درصد مؤثر قرارداد برای یک نوع خدمت؛ نبودِ ردیف → ستون قدیمی قرارداد. */ @@ -61,7 +64,7 @@ export function ruleOf(contract: TenantContract | null, category: string): Cover return { covered: true, percent: contractPercentFor(contract, category), - franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_rials : 0, + franchise: contract.insurance_kind === 'supplementary' ? contract.franchise_percent : 0, ceiling: contract.annual_ceiling_rials, }; } diff --git a/assets/admin/pages/AppointmentEditPage.test.tsx b/assets/admin/pages/AppointmentEditPage.test.tsx index 2ec6d809..4a8e7719 100644 --- a/assets/admin/pages/AppointmentEditPage.test.tsx +++ b/assets/admin/pages/AppointmentEditPage.test.tsx @@ -70,7 +70,7 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => { const CONTRACT = { insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true, - coverage_percent: 70, franchise_rials: 0, annual_ceiling_rials: null, + coverage_percent: 70, franchise_percent: 0, annual_ceiling_rials: null, category_coverages: { outpatient: 70, inpatient: 30 }, }; diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx index abc6b3d4..df67dd23 100644 --- a/assets/admin/pages/ClaimsPage.tsx +++ b/assets/admin/pages/ClaimsPage.tsx @@ -32,6 +32,15 @@ const PAYMENT_OPTIONS = [ { value: 'unpaid', label: 'وصول‌نشده' }, ]; +/** واژگان مطالبه `base` است، نه `basic` که در قرارداد بیمه به کار می‌رود. */ +const KIND_OPTIONS = [ + { value: '', label: 'همه انواع' }, + { value: 'base', label: 'پایه' }, + { value: 'supplementary', label: 'تکمیلی' }, +]; + +const KIND_LABEL: Record = { base: 'پایه', supplementary: 'تکمیلی' }; + const isoNDaysAgo = (days: number): string => { const d = new Date(); d.setDate(d.getDate() - days); @@ -69,6 +78,7 @@ export default function ClaimsPage() { const search = params.get('search') ?? ''; const status = params.get('status') ?? ''; const insuranceId = params.get('insurance_id') ?? ''; + const kind = params.get('kind') ?? ''; const paymentStatus = params.get('payment_status') ?? ''; const from = params.get('from') ?? ''; const to = params.get('to') ?? ''; @@ -76,7 +86,7 @@ export default function ClaimsPage() { const sort = params.get('sort') ?? 'last_activity_at'; const dir = (params.get('dir') ?? 'desc') as 'asc' | 'desc'; - const hasFilters = !!(search || status || insuranceId || paymentStatus || from || to); + const hasFilters = !!(search || status || insuranceId || kind || paymentStatus || from || to); /** تغییر فیلتر همیشه به صفحه‌ی اول برمی‌گردد؛ ماندن روی صفحه ۵ با نتیجه‌ی جدید بی‌معناست. */ const setParam = (patch: Record) => { @@ -95,13 +105,14 @@ export default function ClaimsPage() { if (search) qs.set('search', search); if (status) qs.set('status', status); if (insuranceId) qs.set('insurance_id', insuranceId); + if (kind) qs.set('kind', kind); if (paymentStatus) qs.set('payment_status', paymentStatus); const fromUnix = toUnix(from); const toUnixVal = endOfDayUnix(to); if (fromUnix) qs.set('from', String(fromUnix)); if (toUnixVal) qs.set('to', String(toUnixVal)); return qs.toString(); - }, [page, sort, dir, search, status, insuranceId, paymentStatus, from, to]); + }, [page, sort, dir, search, status, insuranceId, kind, paymentStatus, from, to]); const listQuery = useQuery>({ queryKey: ['claims-by-patient', queryString], @@ -142,6 +153,25 @@ export default function ClaimsPage() { { key: 'full_name', header: 'بیمار', sortable: true, render: (r) => r.full_name ?? '—' }, { key: 'mobile', header: 'موبایل', render: (r) => r.mobile ?? '—' }, { key: 'national_code', header: 'کد ملی', render: (r) => r.national_code ?? '—' }, + { + key: 'insurances', + header: 'بیمه', + render: (r) => ( + (r.insurances ?? []).length === 0 ? '—' : ( +
+ {r.insurances.map((i) => ( + + {i.insurance_name ?? `بیمه #${i.insurance_id}`} + + ))} +
+ ) + ), + }, { key: 'claims_count', header: 'تعداد درخواست', sortable: true, render: (r) => formatNumber(r.claims_count) }, { key: 'total_services_rials', header: 'مجموع خدمات', sortable: true, render: (r) => formatRial(r.total_services_rials) }, { key: 'total_insurance_rials', header: 'سهم بیمه', sortable: true, render: (r) => formatRial(r.total_insurance_rials) }, @@ -176,6 +206,16 @@ export default function ClaimsPage() { height={38} />
+
+ + setParam({ kind: v ? String(v) : '' })} + placeholder="همه انواع" + height={38} + /> +
setParam({ search: v })} - searchPlaceholder="نام، موبایل یا کد ملی بیمار" + searchPlaceholder="نام، موبایل، کد ملی بیمار یا نام بیمه" emptyMessage="پرونده بیمه‌ای ثبت نشده است." sortKey={sort} sortDir={dir} diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 2dcc28dd..9a293473 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -43,7 +43,7 @@ interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; - franchise_rials: number | null; + franchise_percent: number | null; ceiling_rials: number | null; } @@ -278,7 +278,7 @@ function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance ) : ( پوشش {formatNumber(percent)}٪ - {row?.franchise_rials ? فرانشیز {formatRial(row.franchise_rials)} : null} + {row?.franchise_percent ? فرانشیز {formatNumber(row.franchise_percent)}٪ : null} {row?.ceiling_rials ? سقف {formatRial(row.ceiling_rials)} : null} {!row && (ارث از قرارداد)} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index c2b3f3e3..fb574c12 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -229,9 +229,17 @@ export interface ClaimPatientRow { total_approved_rials: number; total_paid_rials: number; overall_status: ClaimStatus; + /** بیمه‌هایی که این بیمار زیرشان مطالبه دارد — واژگان مطالبه: base | supplementary. */ + insurances: ClaimInsuranceRef[]; last_activity_at: number; } +export interface ClaimInsuranceRef { + insurance_id: number; + insurance_name: string | null; + kind: string | null; +} + export interface ClaimStatusLogEntry { uuid: string; from_status: string | null; diff --git a/migrations/Version20260728180000.php b/migrations/Version20260728180000.php new file mode 100644 index 00000000..c3a55e0c --- /dev/null +++ b/migrations/Version20260728180000.php @@ -0,0 +1,44 @@ +addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent NUMERIC(5, 2) NOT NULL'); + $this->addSql('UPDATE tenant_insurances SET franchise_percent = 0'); + + $this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent NUMERIC(5, 2) DEFAULT NULL'); + $this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL'); + } + + public function down(Schema $schema): void + { + // The rial amounts are gone for good; down() restores the shape, not the data. + $this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_percent franchise_rials INT DEFAULT 0 NOT NULL'); + $this->addSql('UPDATE tenant_insurances SET franchise_rials = 0'); + + $this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_percent franchise_rials INT DEFAULT NULL'); + $this->addSql('UPDATE tenant_service_coverage SET franchise_rials = NULL'); + } +} diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php index b819ecd2..912f25e3 100644 --- a/src/Billing/Controller/BillingController.php +++ b/src/Billing/Controller/BillingController.php @@ -330,6 +330,10 @@ class BillingController extends BaseController } $filters = $this->claimFilters($request); + if ($filters['kind'] !== null && !in_array($filters['kind'], [Claim::KIND_BASE, Claim::KIND_SUPPLEMENTARY], true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع بیمه نامعتبر است', 422, 'kind'); + } + $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 20))); $sort = (string) $request->query->get('sort', 'last_activity_at'); @@ -423,6 +427,7 @@ class BillingController extends BaseController return [ 'status' => $request->query->get('status') ?: null, 'insurance_id' => $request->query->get('insurance_id') ?: null, + 'kind' => $request->query->get('kind') ?: null, 'doctor_id' => $request->query->get('doctor_id') ?: null, 'payment_status' => $request->query->get('payment_status') ?: null, 'from' => $request->query->get('from') ?: null, diff --git a/src/Billing/Repository/ClaimRepository.php b/src/Billing/Repository/ClaimRepository.php index a46799d0..19c5e2c9 100644 --- a/src/Billing/Repository/ClaimRepository.php +++ b/src/Billing/Repository/ClaimRepository.php @@ -169,11 +169,41 @@ class ClaimRepository extends ServiceEntityRepository 'total_approved_rials' => (int) $r['total_approved_rials'], 'total_paid_rials' => (int) $r['total_paid_rials'], 'overall_status' => count($statuses) === 1 ? reset($statuses) : 'mixed', + 'insurances' => self::parseInsurances((string) ($r['insurances'] ?? '')), 'last_activity_at' => (int) $r['last_activity_at'], ]; }, $rows); } + /** + * ردیف‌های GROUP_CONCAT بیمه‌های یک بیمار → آرایهٔ ساخت‌یافته. یک بیمار می‌تواند + * مطالبه زیر چند بیمه داشته باشد، پس ستون «بیمه» یک لیست است نه یک مقدار. + * + * @return list + */ + private static function parseInsurances(string $concatenated): array + { + if ($concatenated === '') { + return []; + } + + $rows = []; + foreach (explode('~', $concatenated) as $chunk) { + [$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null); + if ($id === null || $id === '') { + continue; + } + + $rows[] = [ + 'insurance_id' => (int) $id, + 'insurance_name' => $name === '' ? null : $name, + 'kind' => $kind === '' ? null : $kind, + ]; + } + + return $rows; + } + public function countPatientsWithClaims(string $entityType, int $entityId, array $filters): int { [$where, $params] = $this->patientAggregateFilters($filters); @@ -222,12 +252,14 @@ class ClaimRepository extends ServiceEntityRepository WHERE i3.id IN (SELECT DISTINCT cm3.invoice_id FROM claim_map cm3 WHERE cm3.record_id = pr.id) ), 0) AS total_patient_rials, GROUP_CONCAT(DISTINCT c.status) AS statuses, + GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, COALESCE(ins.name, ''), c.insurance_kind) SEPARATOR '~') AS insurances, MAX(c.updated_at) AS last_activity_at FROM claim_map cm JOIN claims c ON c.id = cm.claim_id JOIN invoices inv ON inv.id = cm.invoice_id JOIN patient_records pr ON pr.id = cm.record_id JOIN users u ON u.id = pr.user_id + LEFT JOIN insurances ins ON ins.id = c.insurance_id {$where} GROUP BY pr.id, u.uuid, pr.uuid, u.real_name, u.mobile_number, u.national_code SQL; @@ -250,6 +282,10 @@ class ClaimRepository extends ServiceEntityRepository $conditions[] = 'c.insurance_id = :insId'; $params['insId'] = (int) $filters['insurance_id']; } + if (!empty($filters['kind'])) { + $conditions[] = 'c.insurance_kind = :kind'; + $params['kind'] = (string) $filters['kind']; + } if (!empty($filters['from'])) { $conditions[] = 'c.created_at >= :from'; $params['from'] = (int) $filters['from']; @@ -270,7 +306,10 @@ class ClaimRepository extends ServiceEntityRepository : 'c.status <> \'paid\''; } if (!empty($filters['search'])) { - $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)'; + // نام بیمه هم جستجو می‌شود: کاربر «آسیا» را می‌نویسد و انتظار دارد بیماران + // همان بیمه بیایند، نه فقط بیماری که اسمش آسیاست. + $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search' + . ' OR u.national_code LIKE :search OR ins.name LIKE :search)'; $params['search'] = '%' . trim((string) $filters['search']) . '%'; } @@ -328,6 +367,7 @@ class ClaimRepository extends ServiceEntityRepository LEFT JOIN patient_sessions ps ON ps.id = inv.patient_session_id LEFT JOIN appointments a ON a.id = ps.appointment_id LEFT JOIN doctors d ON d.id = a.doctor_id + LEFT JOIN insurances ins ON ins.id = c.insurance_id {$where} ORDER BY c.created_at DESC, c.id DESC SQL; diff --git a/src/Billing/Service/BillingCalculator.php b/src/Billing/Service/BillingCalculator.php index 23a37fb1..b582cee1 100644 --- a/src/Billing/Service/BillingCalculator.php +++ b/src/Billing/Service/BillingCalculator.php @@ -10,7 +10,7 @@ class BillingCalculator { /** * محاسبه‌ی سهم برای یک آیتم. - * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل روی باقیمانده (با سقف) → فرانشیز تکمیلی. + * ترتیب: کل → پوشش پایه (با سقف) → باقیمانده → تعهد مکمل روی باقیمانده منهای فرانشیز (با سقف). */ public function calculateItem( Money $total, @@ -30,17 +30,19 @@ class BillingCalculator $suppShare = Money::zero(); if ($supplementary !== null && $supplementary->covered) { - $suppShare = $remaining->percent($supplementary->coveragePercent); + // فرانشیز سهم اجباری بیمار از همین مبلغ است و از تعهد تکمیلی کسر می‌شود — + // نه اینکه روی سهم بیمار سوار شود، وگرنه جمع سهم‌ها از کل بیشتر می‌شد و + // مطالبهٔ ارسالی به بیمه بیش از سهم واقعی‌اش می‌بود. + $suppShare = $remaining->percent($supplementary->coveragePercent) + ->sub($remaining->percent($supplementary->franchisePercent)); + if ($supplementary->ceilingRials !== null) { $suppShare = $suppShare->min(new Money($supplementary->ceilingRials)); } - $remaining = $remaining->sub($suppShare); } - // بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ - // تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمی‌کند. - $franchise = new Money($supplementary?->franchiseRials ?? 0); - $patient = $remaining->add($franchise)->min($total); + // بیمهٔ پایه صرفاً درصدی است و فرانشیزش در محاسبه دخالت نمی‌کند. + $patient = $total->sub($baseShare)->sub($suppShare); return new ShareBreakdown( totalRials: $total->rials, diff --git a/src/Insurance/Command/SeedInsuranceScenarioCommand.php b/src/Insurance/Command/SeedInsuranceScenarioCommand.php new file mode 100644 index 00000000..e6d53a7a --- /dev/null +++ b/src/Insurance/Command/SeedInsuranceScenarioCommand.php @@ -0,0 +1,326 @@ + 'تامین اجتماعی', 'kind' => 'basic', 'outpatient' => 30.0, 'inpatient' => 40.0, 'franchise' => 0.0, 'ceiling' => null], + ['name' => 'بیمه ایران', 'kind' => 'supplementary', 'outpatient' => 90.0, 'inpatient' => 80.0, 'franchise' => 10.0, 'ceiling' => 50_000_000], + ['name' => 'بیمه آسیا', 'kind' => 'supplementary', 'outpatient' => 70.0, 'inpatient' => 60.0, 'franchise' => 20.0, 'ceiling' => null], + ['name' => 'بیمه دی', 'kind' => 'supplementary', 'outpatient' => 100.0, 'inpatient' => 50.0, 'franchise' => 0.0, 'ceiling' => 10_000_000], + ]; + + /** + * بیمارهای سناریو. `base`/`supp` نام بیمه‌اند، `visit` قیمت ویزیت به ریال و + * `claim` وضعیت نهاییِ مطالبه‌ها. + */ + private const PATIENTS = [ + ['name' => 'زهرا رضایی', 'base' => 'تامین اجتماعی', 'supp' => null, 'visit' => 5_000_000, 'category' => 'outpatient', 'claim' => Claim::STATUS_PENDING], + ['name' => 'علی محمدی', 'base' => 'تامین اجتماعی', 'supp' => 'بیمه ایران', 'visit' => 8_000_000, 'category' => 'outpatient', 'claim' => Claim::STATUS_SUBMITTED], + ['name' => 'مریم کاظمی', 'base' => null, 'supp' => 'بیمه آسیا', 'visit' => 6_000_000, 'category' => 'inpatient', 'claim' => Claim::STATUS_APPROVED], + ['name' => 'حسین نوروزی', 'base' => 'تامین اجتماعی', 'supp' => 'بیمه دی', 'visit' => 40_000_000, 'category' => 'inpatient', 'claim' => Claim::STATUS_PAID], + ]; + + public function __construct( + private readonly EntityManagerInterface $em, + private readonly DoctorRepository $doctorRepo, + private readonly InsuranceRepository $insuranceRepo, + private readonly TenantInsuranceService $tenantInsuranceService, + private readonly TenantServiceCategoryService $serviceCategories, + private readonly InvoiceService $invoiceService, + private readonly ClaimService $claimService, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('doctor-mobile', null, InputOption::VALUE_REQUIRED, 'موبایل کاربرِ پزشک', '09389388131') + ->addOption('purge', null, InputOption::VALUE_NONE, 'فقط پاک‌سازی داده‌ی سناریو، بدون ساخت دوباره'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $mobile = (string) $input->getOption('doctor-mobile'); + + $doctor = $this->doctorRepo->createQueryBuilder('d') + ->join('d.user', 'u') + ->andWhere('u.mobileNumber = :mobile') + ->setParameter('mobile', $mobile) + ->getQuery() + ->getOneOrNullResult(); + + if ($doctor === null) { + $io->error(sprintf('پزشکی با موبایل %s یافت نشد', $mobile)); + return Command::FAILURE; + } + + $entityType = TenantInsurance::TYPE_DOCTOR; + $entityId = (int) $doctor->getId(); + $io->title(sprintf('سناریوی بیمه — %s (doctor #%d)', $doctor->getName(), $entityId)); + + // پاک‌سازی همیشه انجام می‌شود تا اجرای دوباره داده‌ی تکراری نسازد؛ marker موبایل + // مخصوص همین سناریوست و به داده‌ی واقعی نمی‌رسد. + $purged = $this->purge($entityType, $entityId); + $io->text(sprintf('پاک‌سازی: %d بیمار سناریو حذف شد', $purged)); + + if ($input->getOption('purge')) { + $io->success('فقط پاک‌سازی انجام شد.'); + return Command::SUCCESS; + } + + $this->serviceCategories->save($entityType, $entityId, [ + ['key' => 'outpatient', 'enabled' => true], + ['key' => 'inpatient', 'enabled' => true], + ]); + + $contracts = $this->seedContracts($entityType, $entityId, $io); + if ($contracts === []) { + return Command::FAILURE; + } + + $rows = []; + foreach (self::PATIENTS as $i => $spec) { + $rows[] = $this->seedPatient($entityType, $entityId, $i, $spec); + } + + $io->section('قراردادهای بیمه'); + $io->table( + ['بیمه', 'نوع', 'سرپایی', 'بستری', 'فرانشیز', 'سقف سالانه'], + array_map(static fn(array $c) => [ + $c['name'], + $c['kind'] === 'basic' ? 'پایه' : 'تکمیلی', + $c['outpatient'] . '٪', + $c['inpatient'] . '٪', + $c['franchise'] . '٪', + $c['ceiling'] === null ? 'نامحدود' : number_format($c['ceiling']), + ], self::CONTRACTS), + ); + + $io->section('بیماران و مطالبات'); + $io->table(['بیمار', 'پایه', 'تکمیلی', 'کل', 'سهم بیمه', 'سهم بیمار', 'وضعیت'], $rows); + $io->success('سناریو ساخته شد. ورود به پنل با ' . $mobile); + + return Command::SUCCESS; + } + + /** + * @return array کلید = نام بیمه + */ + private function seedContracts(string $entityType, int $entityId, SymfonyStyle $io): array + { + $contracts = []; + foreach (self::CONTRACTS as $spec) { + $insurance = $this->insuranceRepo->findOneBy(['name' => $spec['name']]); + if ($insurance === null) { + $io->error(sprintf('بیمهٔ «%s» در کاتالوگ نیست؛ اول کاتالوگ بیمه را seed کنید', $spec['name'])); + return []; + } + + $contract = $this->tenantInsuranceService->activate( + $entityType, + $entityId, + (int) $insurance->getId(), + $spec['outpatient'], + $spec['franchise'], + $spec['ceiling'], + null, + null, + $spec['kind'], + ); + + $this->tenantInsuranceService->setCategoryCoverages($contract, [ + ['key' => 'outpatient', 'coverage_percent' => $spec['outpatient']], + ['key' => 'inpatient', 'coverage_percent' => $spec['inpatient']], + ]); + + $contracts[$spec['name']] = $contract; + } + + return $contracts; + } + + /** + * @param array{name: string, base: ?string, supp: ?string, visit: int, category: string, claim: string} $spec + * @return list ردیف جدول خلاصه + */ + private function seedPatient(string $entityType, int $entityId, int $index, array $spec): array + { + $mobile = self::PATIENT_MOBILE_PREFIX . str_pad((string) ($index + 1), 3, '0', STR_PAD_LEFT); + + $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]) ?? new User($mobile); + $user->setRealName($spec['name'])->setRoles(['ROLE_USER'])->setStatus(1); + $this->em->persist($user); + $this->em->flush(); + + $record = new PatientRecord($entityType, $entityId, $user, $entityType, $entityId); + $this->em->persist($record); + $this->em->flush(); + + $session = new PatientSession($record); + $session->setVisitPriceRials($spec['visit']) + ->setInsuranceBaseId($this->insuranceIdOf($spec['base'])) + ->setInsuranceSupplementaryId($this->insuranceIdOf($spec['supp'])) + ->setInsuranceServiceCategory(ServiceCategory::from($spec['category'])) + ->setSessionAt(time()); + $this->em->persist($session); + $this->em->flush(); + + $invoice = $this->invoiceService->createFromSession($session, $entityType, $entityId); + $this->invoiceService->finalize($invoice); + + $claims = $this->claimService->createFromInvoice($invoice); + foreach ($claims as $claim) { + $this->advanceClaim($claim, $spec['claim']); + } + + $insuranceShare = $invoice->getTotalRials() - $invoice->getPatientRials(); + + return [ + $spec['name'], + $spec['base'] ?? '—', + $spec['supp'] ?? '—', + number_format($invoice->getTotalRials()), + number_format($insuranceShare), + number_format($invoice->getPatientRials()), + $spec['claim'], + ]; + } + + /** مطالبه را تا وضعیت هدف جلو می‌برد؛ هر گام از همان transition واقعی رد می‌شود. */ + private function advanceClaim(Claim $claim, string $target): void + { + $path = match ($target) { + Claim::STATUS_SUBMITTED => [Claim::STATUS_SUBMITTED], + Claim::STATUS_APPROVED => [Claim::STATUS_SUBMITTED, Claim::STATUS_APPROVED], + Claim::STATUS_PAID => [Claim::STATUS_SUBMITTED, Claim::STATUS_APPROVED, Claim::STATUS_PAID], + Claim::STATUS_REJECTED => [Claim::STATUS_SUBMITTED, Claim::STATUS_REJECTED], + default => [], + }; + + foreach ($path as $step) { + $this->claimService->transition($claim, $step, [ + 'approved_rials' => $step === Claim::STATUS_APPROVED ? $claim->getTotalClaimedRials() : null, + 'paid_rials' => $step === Claim::STATUS_PAID ? $claim->getTotalApprovedRials() : null, + 'reason' => $step === Claim::STATUS_REJECTED ? 'مدارک ناقص است' : '', + 'tracking_number' => $step === Claim::STATUS_SUBMITTED ? 'SC-' . $claim->getId() : null, + ]); + } + } + + private function insuranceIdOf(?string $name): ?int + { + if ($name === null) { + return null; + } + + return $this->insuranceRepo->findOneBy(['name' => $name])?->getId(); + } + + /** حذف بیماران سناریو و هرچه از آن‌ها آویزان است. قراردادها با activate بازنویسی می‌شوند. */ + private function purge(string $entityType, int $entityId): int + { + $conn = $this->em->getConnection(); + $ids = $conn->executeQuery( + 'SELECT id FROM users WHERE mobile_number LIKE :prefix', + ['prefix' => self::PATIENT_MOBILE_PREFIX . '%'], + )->fetchFirstColumn(); + + if ($ids === []) { + return 0; + } + + $params = ['ids' => $ids, 'type' => $entityType, 'entity' => $entityId]; + $types = ['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]; + + $conn->executeStatement( + 'DELETE ci FROM claim_items ci + JOIN invoice_items ii ON ii.id = ci.invoice_item_id + JOIN invoices inv ON inv.id = ii.invoice_id + JOIN patient_records pr ON pr.id = inv.patient_record_id + WHERE pr.user_id IN (:ids) AND pr.entity_type = :type AND pr.entity_id = :entity', + $params, + $types, + ); + $conn->executeStatement( + 'DELETE csl FROM claim_status_logs csl + JOIN claims c ON c.id = csl.claim_id + LEFT JOIN claim_items ci ON ci.claim_id = c.id + WHERE ci.id IS NULL AND c.entity_type = :type AND c.entity_id = :entity', + ['type' => $entityType, 'entity' => $entityId], + ); + $conn->executeStatement( + 'DELETE c FROM claims c + LEFT JOIN claim_items ci ON ci.claim_id = c.id + WHERE ci.id IS NULL AND c.entity_type = :type AND c.entity_id = :entity', + ['type' => $entityType, 'entity' => $entityId], + ); + $conn->executeStatement( + 'DELETE ii FROM invoice_items ii + JOIN invoices inv ON inv.id = ii.invoice_id + JOIN patient_records pr ON pr.id = inv.patient_record_id + WHERE pr.user_id IN (:ids)', + $params, + $types, + ); + $conn->executeStatement( + 'DELETE inv FROM invoices inv + JOIN patient_records pr ON pr.id = inv.patient_record_id + WHERE pr.user_id IN (:ids)', + $params, + $types, + ); + $conn->executeStatement( + 'DELETE ps FROM patient_sessions ps + JOIN patient_records pr ON pr.id = ps.record_id + WHERE pr.user_id IN (:ids)', + $params, + $types, + ); + $conn->executeStatement('DELETE FROM patient_records WHERE user_id IN (:ids)', $params, $types); + $conn->executeStatement('DELETE FROM users WHERE id IN (:ids)', $params, $types); + + return count($ids); + } +} diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index da3e7c93..e446c7b3 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -499,7 +499,7 @@ class InsuranceController extends BaseController $entityId, $insuranceId, (float) ($data['coverage_percent'] ?? 0), - (int) ($data['franchise_rials'] ?? 0), + (float) ($data['franchise_percent'] ?? 0), isset($data['annual_ceiling_rials']) && $data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null, isset($data['effective_from']) && $data['effective_from'] !== null ? (int) $data['effective_from'] : null, @@ -561,8 +561,12 @@ class InsuranceController extends BaseController if (array_key_exists('coverage_percent', $data)) { $contract->setCoveragePercent((float) $data['coverage_percent']); } - if (array_key_exists('franchise_rials', $data)) { - $contract->setFranchiseRials((int) $data['franchise_rials']); + if (array_key_exists('franchise_percent', $data)) { + $percent = (float) $data['franchise_percent']; + if ($percent < 0 || $percent > 100) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent'); + } + $contract->setFranchisePercent($percent); } if (array_key_exists('annual_ceiling_rials', $data)) { $contract->setAnnualCeilingRials($data['annual_ceiling_rials'] !== null ? (int) $data['annual_ceiling_rials'] : null); @@ -682,7 +686,7 @@ class InsuranceController extends BaseController $serviceItemId, (bool) ($data['covered'] ?? true), isset($data['coverage_percent']) && $data['coverage_percent'] !== null ? (float) $data['coverage_percent'] : null, - isset($data['franchise_rials']) && $data['franchise_rials'] !== null ? (int) $data['franchise_rials'] : null, + isset($data['franchise_percent']) && $data['franchise_percent'] !== null ? (float) $data['franchise_percent'] : null, isset($data['ceiling_rials']) && $data['ceiling_rials'] !== null ? (int) $data['ceiling_rials'] : null, ); diff --git a/src/Insurance/Entity/TenantInsurance.php b/src/Insurance/Entity/TenantInsurance.php index d63eaae5..efa1a1f7 100644 --- a/src/Insurance/Entity/TenantInsurance.php +++ b/src/Insurance/Entity/TenantInsurance.php @@ -41,8 +41,9 @@ class TenantInsurance #[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2)] private string $coveragePercent = '0.00'; - #[ORM\Column(name: 'franchise_rials', type: 'integer')] - private int $franchiseRials = 0; + /** درصدِ سهم اجباری بیمار از مبلغ تحت پوشش؛ فقط در قرارداد تکمیلی اثر دارد. */ + #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)] + private string $franchisePercent = '0.00'; #[ORM\Column(name: 'annual_ceiling_rials', type: 'integer', nullable: true)] private ?int $annualCeilingRials = null; @@ -83,7 +84,7 @@ class TenantInsurance public function getVersion(): int { return $this->version; } public function isActive(): bool { return $this->isActive; } public function getCoveragePercent(): float { return (float) $this->coveragePercent; } - public function getFranchiseRials(): int { return $this->franchiseRials; } + public function getFranchisePercent(): float { return (float) $this->franchisePercent; } public function getAnnualCeilingRials(): ?int { return $this->annualCeilingRials; } public function getKind(): ?string { return $this->kind; } public function getEffectiveFrom(): int { return $this->effectiveFrom; } @@ -91,7 +92,7 @@ class TenantInsurance public function setActive(bool $v): self { $this->isActive = $v; $this->updatedAt = time(); return $this; } public function setCoveragePercent(float $v): self { $this->coveragePercent = (string) $v; $this->updatedAt = time(); return $this; } - public function setFranchiseRials(int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; } + public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; } public function setAnnualCeilingRials(?int $v): self { $this->annualCeilingRials = $v; $this->updatedAt = time(); return $this; } public function setKind(?string $v): self { $this->kind = $v; $this->updatedAt = time(); return $this; } public function setEffectiveFrom(int $v): self { $this->effectiveFrom = $v; $this->updatedAt = time(); return $this; } @@ -107,7 +108,7 @@ class TenantInsurance 'version' => $this->version, 'is_active' => $this->isActive, 'coverage_percent' => (float) $this->coveragePercent, - 'franchise_rials' => $this->franchiseRials, + 'franchise_percent' => (float) $this->franchisePercent, 'annual_ceiling_rials' => $this->annualCeilingRials, 'kind' => $this->kind, 'effective_from' => $this->effectiveFrom, diff --git a/src/Insurance/Entity/TenantServiceCoverage.php b/src/Insurance/Entity/TenantServiceCoverage.php index 0b0b5d51..dc321fec 100644 --- a/src/Insurance/Entity/TenantServiceCoverage.php +++ b/src/Insurance/Entity/TenantServiceCoverage.php @@ -32,8 +32,9 @@ class TenantServiceCoverage #[ORM\Column(name: 'coverage_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)] private ?string $coveragePercent = null; - #[ORM\Column(name: 'franchise_rials', type: 'integer', nullable: true)] - private ?int $franchiseRials = null; + /** null = ارث از قرارداد؛ درصد است، نه مبلغ. */ + #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2, nullable: true)] + private ?string $franchisePercent = null; #[ORM\Column(name: 'ceiling_rials', type: 'integer', nullable: true)] private ?int $ceilingRials = null; @@ -55,12 +56,12 @@ class TenantServiceCoverage public function getServiceItemId(): int { return $this->serviceItemId; } public function isCovered(): bool { return $this->covered; } public function getCoveragePercent(): ?float { return $this->coveragePercent !== null ? (float) $this->coveragePercent : null; } - public function getFranchiseRials(): ?int { return $this->franchiseRials; } + public function getFranchisePercent(): ?float { return $this->franchisePercent !== null ? (float) $this->franchisePercent : null; } public function getCeilingRials(): ?int { return $this->ceilingRials; } public function setCovered(bool $v): self { $this->covered = $v; $this->updatedAt = time(); return $this; } public function setCoveragePercent(?float $v): self { $this->coveragePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; } - public function setFranchiseRials(?int $v): self { $this->franchiseRials = $v; $this->updatedAt = time(); return $this; } + public function setFranchisePercent(?float $v): self { $this->franchisePercent = $v !== null ? (string) $v : null; $this->updatedAt = time(); return $this; } public function setCeilingRials(?int $v): self { $this->ceilingRials = $v; $this->updatedAt = time(); return $this; } public function toArray(): array @@ -71,7 +72,7 @@ class TenantServiceCoverage 'service_item_id' => $this->serviceItemId, 'covered' => $this->covered, 'coverage_percent' => $this->getCoveragePercent(), - 'franchise_rials' => $this->franchiseRials, + 'franchise_percent' => $this->getFranchisePercent(), 'ceiling_rials' => $this->ceilingRials, ]; } diff --git a/src/Insurance/Service/TenantInsuranceService.php b/src/Insurance/Service/TenantInsuranceService.php index 804fd225..4afe08d5 100644 --- a/src/Insurance/Service/TenantInsuranceService.php +++ b/src/Insurance/Service/TenantInsuranceService.php @@ -30,6 +30,7 @@ class TenantInsuranceService private readonly ServiceItemRepository $serviceItemRepo, private readonly TenantInsuranceCategoryCoverageRepository $categoryCoverageRepo, private readonly InsuranceCoverageDefaultService $coverageDefaults, + private readonly TenantServiceCategoryService $serviceCategories, ) {} /** @@ -41,7 +42,7 @@ class TenantInsuranceService int $entityId, int $insuranceId, float $coveragePercent, - int $franchiseRials = 0, + float $franchisePercent = 0.0, ?int $annualCeilingRials = null, ?int $effectiveFrom = null, ?int $effectiveTo = null, @@ -58,8 +59,10 @@ class TenantInsuranceService $contract = new TenantInsurance($entityType, $entityId, $insuranceId, $version); } + $this->assertPercentInRange($franchisePercent, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 'franchise_percent'); + $contract->setCoveragePercent($coveragePercent) - ->setFranchiseRials($franchiseRials) + ->setFranchisePercent($franchisePercent) ->setAnnualCeilingRials($annualCeilingRials) // kind defaults to the catalog type; caller may override to categorise the contract. ->setKind($kind ?? $insurance->getType()->value) @@ -85,13 +88,18 @@ class TenantInsuranceService /** * Replaces the contract's category overrides. A row whose percentage is null is - * dropped, which hands that category back to the central admin default. + * dropped, which hands that category back to the central admin default — but only + * for a service kind the tenant does not cover; an enabled kind must carry a + * percentage, otherwise the contract would silently bill it at zero. * * @param list $rows - * @throws AppException on an unknown category or an out-of-range percentage + * @throws AppException on an unknown category, an out-of-range percentage, or a + * priced-out enabled service kind */ public function setCategoryCoverages(TenantInsurance $contract, array $rows): void { + $this->assertEnabledCategoriesArePriced($contract, $rows); + foreach ($rows as $row) { $category = ServiceCategory::tryFromValue(isset($row['key']) ? (string) $row['key'] : null); if ($category === null) { @@ -113,9 +121,7 @@ class TenantInsuranceService } $percent = (float) $raw; - if ($percent < 0 || $percent > 100) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 422); - } + $this->assertPercentInRange($percent, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد'); $entity = $existing ?? new TenantInsuranceCategoryCoverage($contract->getId(), $category); $this->categoryCoverageRepo->save($entity->setCoveragePercent($percent), false); @@ -124,6 +130,58 @@ class TenantInsuranceService $this->categoryCoverageRepo->flush(); } + /** + * هر نوع خدمتی که tenant آن را بیمه‌ای کرده باید در پایانِ این ذخیره‌سازی درصد + * پوشش مؤثر داشته باشد — از خودِ payload، از override قبلی، یا از پیش‌فرض مرکزی + * ادمین. fallback زنده حفظ می‌شود؛ چیزی که رد می‌شود قراردادی است که نوع خدمتِ + * فعال را عملاً صفر درصد می‌کند. + * + * نیامدنِ کلید `category_coverages` اصلاً به اینجا نمی‌رسد — آن حالت یعنی + * «قرارداد دست‌نخورده روی همان مسیر resolve بماند». + * + * @param list $rows + * @throws AppException وقتی نوع خدمتِ فعالی بدون درصد مؤثر بماند + */ + private function assertEnabledCategoriesArePriced(TenantInsurance $contract, array $rows): void + { + $sent = []; + foreach ($rows as $row) { + $sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null; + } + + $overrides = $this->categoryCoverageRepo->percentMapFor($contract->getId()); + $defaults = $this->coverageDefaults->percentMap($contract->getInsuranceId()); + + foreach ($this->serviceCategories->enabledKeys($contract->getEntityType(), $contract->getEntityId()) as $key) { + // ارسال صریحِ null یعنی «override را بردار»، پس نباید خودِ همان override + // که همین حالا حذف می‌شود، اعتبارسنجی را نجات بدهد. + // ستون قدیمیِ coverage_percent قرارداد عمداً fallback حساب نمی‌شود: با آن، + // نوع خدمتی که درصدش نیامده بی‌صدا نرخ نوع دیگر را ارث می‌برد. + $percent = match (true) { + array_key_exists($key, $sent) && $sent[$key] !== null && $sent[$key] !== '' => (float) $sent[$key], + array_key_exists($key, $sent) => $defaults[$key] ?? 0.0, + default => $overrides[$key] ?? $defaults[$key] ?? 0.0, + }; + + if ($percent <= 0) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()), + 422, + 'category_coverages', + ); + } + } + } + + /** @throws AppException وقتی درصد بیرون از بازهٔ ۰ تا ۱۰۰ باشد */ + private function assertPercentInRange(float $percent, string $message, ?string $field = null): void + { + if ($percent < 0 || $percent > 100) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, $message, 422, $field); + } + } + /** * Effective percentage per category plus where each value came from, so the panel * can tell an explicit override apart from an inherited central default. @@ -277,14 +335,14 @@ class TenantInsuranceService ?TenantServiceCoverage $override, ): CoverageRule { $franchise = $this->isSupplementary($contract) - ? ($override?->getFranchiseRials() ?? $contract->getFranchiseRials()) - : 0; + ? ($override?->getFranchisePercent() ?? $contract->getFranchisePercent()) + : 0.0; return new CoverageRule( - coveragePercent: $this->resolvePercent($contract, $category, $override), - franchiseRials: $franchise, - ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(), - covered: true, + coveragePercent: $this->resolvePercent($contract, $category, $override), + franchisePercent: $franchise, + ceilingRials: $override?->getCeilingRials() ?? $contract->getAnnualCeilingRials(), + covered: true, ); } @@ -324,7 +382,7 @@ class TenantInsuranceService return $kind === InsuranceType::Supplementary->value; } - /** @return array{covered: bool, percent: float|null, franchise: int|null, ceiling: int|null}|null */ + /** @return array{covered: bool, coverage_percent: float|null, franchise_percent: float|null, ceiling_rials: int|null}|null */ public function getServiceCoverage(int $tenantInsuranceId, int $serviceItemId): ?array { $override = $this->coverageRepo->findOneFor($tenantInsuranceId, $serviceItemId); @@ -336,15 +394,22 @@ class TenantInsuranceService int $serviceItemId, bool $covered, ?float $coveragePercent, - ?int $franchiseRials, + ?float $franchisePercent, ?int $ceilingRials, ): void { + if ($coveragePercent !== null) { + $this->assertPercentInRange($coveragePercent, 'درصد پوشش باید بین ۰ تا ۱۰۰ باشد', 'coverage_percent'); + } + if ($franchisePercent !== null) { + $this->assertPercentInRange($franchisePercent, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 'franchise_percent'); + } + $override = $this->coverageRepo->findOneFor($contract->getId(), $serviceItemId) ?? new TenantServiceCoverage($contract->getId(), $serviceItemId); $override->setCovered($covered) ->setCoveragePercent($coveragePercent) - ->setFranchiseRials($franchiseRials) + ->setFranchisePercent($franchisePercent) ->setCeilingRials($ceilingRials); $this->coverageRepo->save($override); diff --git a/src/Insurance/ValueObject/CoverageRule.php b/src/Insurance/ValueObject/CoverageRule.php index a3de1839..f6484db2 100644 --- a/src/Insurance/ValueObject/CoverageRule.php +++ b/src/Insurance/ValueObject/CoverageRule.php @@ -6,14 +6,17 @@ final readonly class CoverageRule { public function __construct( public float $coveragePercent, - /** فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمی‌کند. */ - public int $franchiseRials, + /** + * درصدِ سهم اجباری بیمار از مبلغ تحت پوشش — از سهم بیمه کسر می‌شود. + * فقط برای بیمهٔ تکمیلی معنا دارد؛ در بیمهٔ پایه در محاسبه دخالت نمی‌کند. + */ + public float $franchisePercent, public ?int $ceilingRials, public bool $covered = true, ) {} public static function notCovered(): self { - return new self(0.0, 0, null, false); + return new self(0.0, 0.0, null, false); } } diff --git a/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php b/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php index ac6de07e..50e33362 100644 --- a/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php +++ b/src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php @@ -27,7 +27,7 @@ class NumericFieldNormalizerSubscriber implements EventSubscriberInterface 'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials', 'insurance_price_rials', 'patient_share_rials', 'visit_price_rials', 'duration_minutes', 'duration', 'commission_percent', 'coverage', - 'coverage_percent', 'franchise', 'ceiling', 'tax_percent', + 'coverage_percent', 'franchise', 'franchise_percent', 'ceiling', 'tax_percent', 'base_insurance_discount_percent', 'supplementary_discount_percent', ]; diff --git a/tests/Billing/BillingCalculatorTest.php b/tests/Billing/BillingCalculatorTest.php index 8fc72aa7..73003447 100644 --- a/tests/Billing/BillingCalculatorTest.php +++ b/tests/Billing/BillingCalculatorTest.php @@ -27,8 +27,8 @@ class BillingCalculatorTest extends TestCase public function testBaseAndSupplementary(): void { // کل 600,000؛ پایه 70% → 420,000؛ مکمل روی 180,000 با ~66.667% → 120,000؛ بیمار 60,000 - $base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null); - $supp = new CoverageRule(coveragePercent: 66.6667, franchiseRials: 0, ceilingRials: null); + $base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: null); + $supp = new CoverageRule(coveragePercent: 66.6667, franchisePercent: 0, ceilingRials: null); $b = $this->calc->calculateItem(new Money(600_000), $base, $supp); @@ -43,7 +43,7 @@ class BillingCalculatorTest extends TestCase public function testBaseOnly(): void { - $base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: null); + $base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: null); $b = $this->calc->calculateItem(new Money(600_000), $base, null); $this->assertSame(420_000, $b->baseInsuranceRials); $this->assertSame(0, $b->supplementaryRials); @@ -53,7 +53,7 @@ class BillingCalculatorTest extends TestCase public function testBaseCeilingCapsShare(): void { // پایه 70% = 420,000 ولی سقف 300,000 → بیمار باقی را می‌دهد - $base = new CoverageRule(coveragePercent: 70, franchiseRials: 0, ceilingRials: 300_000); + $base = new CoverageRule(coveragePercent: 70, franchisePercent: 0, ceilingRials: 300_000); $b = $this->calc->calculateItem(new Money(600_000), $base, null); $this->assertSame(300_000, $b->baseInsuranceRials); $this->assertSame(300_000, $b->patientRials); @@ -62,7 +62,7 @@ class BillingCalculatorTest extends TestCase /** سناریوی مرجع کاربر: ویزیت 5,952,000 ریال با پوشش پایهٔ ۳۰٪ (بستری). */ public function testReferenceScenarioBasePercentOnly(): void { - $base = new CoverageRule(coveragePercent: 30.0, franchiseRials: 0, ceilingRials: null); + $base = new CoverageRule(coveragePercent: 30.0, franchisePercent: 0, ceilingRials: null); $b = $this->calc->calculateItem(new Money(5_952_000), $base, null); @@ -74,27 +74,66 @@ class BillingCalculatorTest extends TestCase public function testBaseFranchiseDoesNotChargePatient(): void { // فرانشیز در بیمهٔ پایه بی‌اثر است: پایه 100% → سهم بیمار صفر. - $base = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null); + $base = new CoverageRule(coveragePercent: 100, franchisePercent: 50, ceilingRials: null); $b = $this->calc->calculateItem(new Money(600_000), $base, null); $this->assertSame(0, $b->patientRials); $this->assertSame(600_000, $b->baseInsuranceRials); } - public function testSupplementaryFranchiseAddedToPatient(): void + public function testSupplementaryFranchiseIsDeductedFromTheInsurerShare(): void { - $base = new CoverageRule(coveragePercent: 70, franchiseRials: 90_000, ceilingRials: null); - $supp = new CoverageRule(coveragePercent: 100, franchiseRials: 50_000, ceilingRials: null); + // پایه 70% → 420,000؛ باقیمانده 180,000؛ تکمیلی 100% منهای فرانشیز 10% → 162,000. + $base = new CoverageRule(coveragePercent: 70, franchisePercent: 50, ceilingRials: null); + $supp = new CoverageRule(coveragePercent: 100, franchisePercent: 10, ceilingRials: null); $b = $this->calc->calculateItem(new Money(600_000), $base, $supp); $this->assertSame(420_000, $b->baseInsuranceRials); - $this->assertSame(180_000, $b->supplementaryRials); - $this->assertSame(50_000, $b->patientRials); + $this->assertSame(162_000, $b->supplementaryRials); + $this->assertSame(18_000, $b->patientRials); + $this->assertSame( + $b->totalRials, + $b->baseInsuranceRials + $b->supplementaryRials + $b->patientRials, + 'فرانشیز نباید جمع سهم‌ها را از کل بیشتر کند' + ); + } + + /** سناریوی مرجع: بدون بیمهٔ پایه، تکمیلی ۹۰٪ با فرانشیز ۱۰٪. */ + public function testSupplementaryOnlyWithFranchise(): void + { + $supp = new CoverageRule(coveragePercent: 90, franchisePercent: 10, ceilingRials: null); + + $b = $this->calc->calculateItem(new Money(1_000_000), null, $supp); + + $this->assertSame(800_000, $b->supplementaryRials); + $this->assertSame(200_000, $b->patientRials); + $this->assertSame($b->totalRials, $b->supplementaryRials + $b->patientRials); + } + + public function testFullFranchiseLeavesNothingForTheSupplementary(): void + { + $supp = new CoverageRule(coveragePercent: 100, franchisePercent: 100, ceilingRials: null); + + $b = $this->calc->calculateItem(new Money(600_000), null, $supp); + + $this->assertSame(0, $b->supplementaryRials); + $this->assertSame(600_000, $b->patientRials); + } + + public function testFranchiseNeverPushesTheInsurerShareBelowZero(): void + { + // فرانشیز بزرگ‌تر از تعهد: سهم بیمه صفر می‌شود، نه منفی. + $supp = new CoverageRule(coveragePercent: 20, franchisePercent: 80, ceilingRials: null); + + $b = $this->calc->calculateItem(new Money(600_000), null, $supp); + + $this->assertSame(0, $b->supplementaryRials); + $this->assertSame(600_000, $b->patientRials); } public function testZeroPercentLeavesEverythingToPatient(): void { - $base = new CoverageRule(coveragePercent: 0, franchiseRials: 0, ceilingRials: null); + $base = new CoverageRule(coveragePercent: 0, franchisePercent: 0, ceilingRials: null); $b = $this->calc->calculateItem(new Money(600_000), $base, null); $this->assertSame(0, $b->baseInsuranceRials); $this->assertSame(600_000, $b->patientRials); @@ -102,7 +141,7 @@ class BillingCalculatorTest extends TestCase public function testFullPercentLeavesNothingToPatient(): void { - $base = new CoverageRule(coveragePercent: 100, franchiseRials: 0, ceilingRials: null); + $base = new CoverageRule(coveragePercent: 100, franchisePercent: 0, ceilingRials: null); $b = $this->calc->calculateItem(new Money(600_000), $base, null); $this->assertSame(600_000, $b->baseInsuranceRials); $this->assertSame(0, $b->patientRials); diff --git a/tests/Billing/ClaimsByPatientTest.php b/tests/Billing/ClaimsByPatientTest.php index 2f3ed127..da36626d 100644 --- a/tests/Billing/ClaimsByPatientTest.php +++ b/tests/Billing/ClaimsByPatientTest.php @@ -9,6 +9,8 @@ use App\Billing\Entity\Invoice; use App\Billing\Entity\InvoiceItem; use App\Billing\ValueObject\ShareBreakdown; use App\Doctor\Entity\Doctor; +use App\Insurance\Entity\Insurance; +use App\Insurance\Enum\InsuranceType; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase; @@ -40,8 +42,13 @@ class ClaimsByPatientTest extends ApiTestCase $this->em->flush(); } - /** Invoice of $total split into insurance/patient shares, with its claim(s). */ - private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending']): Invoice + /** + * Invoice of $total split into insurance/patient shares, with its claim(s). + * + * @param list $insuranceIds one per claim; defaults to synthetic ids when the + * test does not care which insurance it is + */ + private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending'], array $insuranceIds = []): Invoice { $patient = $total - $baseShare - $suppShare; @@ -58,7 +65,7 @@ class ClaimsByPatientTest extends ApiTestCase $kind = $i === 0 ? Claim::KIND_BASE : Claim::KIND_SUPPLEMENTARY; $share = $i === 0 ? $baseShare : $suppShare; - $claim = new Claim('doctor', $this->doctor->getId(), 1 + $i, $kind); + $claim = new Claim('doctor', $this->doctor->getId(), $insuranceIds[$i] ?? (1 + $i), $kind); $claimItem = new ClaimItem($claim, (int) $item->getId(), $share); $claim->addItem($claimItem); if ($status !== Claim::STATUS_PENDING) { @@ -118,6 +125,67 @@ class ClaimsByPatientTest extends ApiTestCase self::assertSame('mixed', $res['data'][0]['overall_status']); } + public function testEachRowCarriesTheInsurancesItsClaimsBelongTo(): void + { + $iran = new Insurance('بیمه ایران تست', InsuranceType::Basic); + $asia = new Insurance('بیمه آسیا تست', InsuranceType::Supplementary); + $this->em->persist($iran); + $this->em->persist($asia); + $this->em->flush(); + + $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending'], [ + (int) $iran->getId(), (int) $asia->getId(), + ]); + + $res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner); + $row = $res['data'][0]; + + self::assertCount(2, $row['insurances']); + $names = array_column($row['insurances'], 'insurance_name'); + self::assertContains('بیمه ایران تست', $names); + self::assertContains('بیمه آسیا تست', $names); + self::assertSame( + ['base', 'supplementary'], + array_values(array_unique(array_column($row['insurances'], 'kind'))), + ); + } + + public function testKindFilterKeepsOnlyThatKindOfClaim(): void + { + $this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending']); + + $supp = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=supplementary', $this->owner); + self::assertSame(1, $supp['data'][0]['claims_count']); + self::assertSame(2_000_000, $supp['data'][0]['total_insurance_rials']); + + $base = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=base', $this->owner); + self::assertSame(6_000_000, $base['data'][0]['total_insurance_rials']); + } + + public function testAnUnknownKindIsRejected(): void + { + $body = $this->authJson('GET', '/api/v1/billing/claims/by-patient?kind=bogus', $this->owner); + + self::assertSame(422, $this->responseCode()); + self::assertSame('kind', $body['errors'][0]['field']); + } + + public function testSearchAlsoMatchesTheInsuranceName(): void + { + $asia = new Insurance('بیمه آسیا جستجو', InsuranceType::Supplementary); + $this->em->persist($asia); + $this->em->flush(); + + $this->invoiceWithClaims(10_000_000, 0, 4_000_000, ['pending'], [(int) $asia->getId()]); + + $hit = $this->authJson('GET', '/api/v1/billing/claims/by-patient?search=' . urlencode('آسیا'), $this->owner); + self::assertSame(1, $hit['meta']['totalRecords']); + + $miss = $this->authJson('GET', '/api/v1/billing/claims/by-patient?search=' . urlencode('بیمه ناموجود'), $this->owner); + self::assertSame(0, $miss['meta']['totalRecords']); + self::assertSame([], $miss['data']); + } + public function testStatusFilterNarrowsTheAggregation(): void { $this->invoiceWithClaims(10_000_000, 7_000_000, 0); diff --git a/tests/Insurance/CoveragePercentResolutionTest.php b/tests/Insurance/CoveragePercentResolutionTest.php index 817d84e1..679afccd 100644 --- a/tests/Insurance/CoveragePercentResolutionTest.php +++ b/tests/Insurance/CoveragePercentResolutionTest.php @@ -107,7 +107,11 @@ class CoveragePercentResolutionTest extends ApiTestCase public function testServiceOverrideWinsOverEveryOtherLevel(): void { [$contract, $insurance, $item] = $this->makeContract(55.0); - $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]); + // سرپایی هم پیش‌فرض دارد، وگرنه ذخیرهٔ درصدها با «درصد پوشش الزامی است» رد می‌شود. + $this->defaults->save($insurance->getId(), [ + ['key' => 'inpatient', 'coverage_percent' => 30], + ['key' => 'outpatient', 'coverage_percent' => 70], + ]); $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]); $this->service->setServiceCoverage($contract, $item->getId(), true, 25.0, null, null); @@ -139,7 +143,7 @@ class CoveragePercentResolutionTest extends ApiTestCase ); $this->assertSame(30.0, $rule->coveragePercent); - $this->assertSame(0, $rule->franchiseRials, 'فرانشیز در بیمهٔ پایه صفر می‌ماند'); + $this->assertSame(0.0, $rule->franchisePercent, 'فرانشیز در بیمهٔ پایه صفر می‌ماند'); } /** ویزیت خدمتِ سرپایی است. */ @@ -163,7 +167,10 @@ class CoveragePercentResolutionTest extends ApiTestCase public function testRemovingTheOverrideReturnsToTheAdminDefault(): void { [$contract, $insurance] = $this->makeContract(55.0); - $this->defaults->save($insurance->getId(), [['key' => 'inpatient', 'coverage_percent' => 30]]); + $this->defaults->save($insurance->getId(), [ + ['key' => 'inpatient', 'coverage_percent' => 30], + ['key' => 'outpatient', 'coverage_percent' => 70], + ]); $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => 90]]); $this->service->setCategoryCoverages($contract, [['key' => 'inpatient', 'coverage_percent' => null]]); diff --git a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php index a1a5e836..cfc081ce 100644 --- a/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php +++ b/tests/Insurance/TenantInsuranceCategoryCoverageApiTest.php @@ -70,6 +70,45 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase $this->assertSame('admin_default', $row['category_coverage_source']['outpatient']); } + public function testAnEnabledServiceKindCannotBeLeftWithoutAPercent(): void + { + // بدون پیش‌فرض مرکزی: ارسال درصد فقط برای سرپایی، بستری را صفر رها می‌کند. + [$owner, $insurance] = $this->makeDoctorAndInsurance(); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'category_coverages' => [ + ['key' => 'outpatient', 'coverage_percent' => 70], + ['key' => 'inpatient', 'coverage_percent' => null], + ], + ]); + + $this->assertSame(422, $this->responseCode()); + $this->assertSame('category_coverages', $body['errors'][0]['field']); + $this->assertStringContainsString('خدمات بستری', $body['errors'][0]['message']); + } + + public function testADisabledServiceKindNeedsNoPercent(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(); + $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'service_categories' => [ + ['key' => 'outpatient', 'enabled' => true], + ['key' => 'inpatient', 'enabled' => false], + ], + ]); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'category_coverages' => [ + ['key' => 'outpatient', 'coverage_percent' => 70], + ], + ]); + + $this->assertSame(201, $this->responseCode()); + $this->assertEquals(70.0, $body['data']['data']['category_coverages']['outpatient']); + } + public function testListReturnsEffectivePercentsAndSources(): void { [$owner, $insurance] = $this->makeDoctorAndInsurance(); @@ -113,8 +152,10 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase public function testPatchCanDropAnOverrideBackToTheAdminDefault(): void { [$owner, $insurance] = $this->makeDoctorAndInsurance(); + // سرپایی هم پیش‌فرض دارد، وگرنه ساخت قرارداد به‌خاطر «درصد پوشش الزامی است» رد می‌شود. static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [ - ['key' => 'inpatient', 'coverage_percent' => 30], + ['key' => 'outpatient', 'coverage_percent' => 50], + ['key' => 'inpatient', 'coverage_percent' => 30], ]); $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ diff --git a/tests/Insurance/TenantInsuranceContractApiTest.php b/tests/Insurance/TenantInsuranceContractApiTest.php index 7435cab2..0194ebec 100644 --- a/tests/Insurance/TenantInsuranceContractApiTest.php +++ b/tests/Insurance/TenantInsuranceContractApiTest.php @@ -35,7 +35,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ 'insurance_id' => $insurance->getId(), 'coverage_percent' => 70, - 'franchise_rials' => 500_000, + 'franchise_percent' => 15, 'annual_ceiling_rials' => 20_000_000, 'effective_from' => 1_700_000_000, 'effective_to' => 1_800_000_000, @@ -45,7 +45,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase $this->assertSame(201, $this->responseCode()); $row = $body['data']['data']; $this->assertEquals(70.0, $row['coverage_percent']); - $this->assertSame(500_000, $row['franchise_rials']); + $this->assertEquals(15.0, $row['franchise_percent']); $this->assertSame(20_000_000, $row['annual_ceiling_rials']); $this->assertSame(1_700_000_000, $row['effective_from']); $this->assertSame(1_800_000_000, $row['effective_to']); @@ -53,6 +53,37 @@ class TenantInsuranceContractApiTest extends ApiTestCase $this->assertTrue($row['is_active']); } + public function testFranchiseAboveHundredIsRejected(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Supplementary); + + $body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 80, + 'franchise_percent' => 150, + ]); + + $this->assertSame(422, $this->responseCode()); + $this->assertSame('franchise_percent', $body['errors'][0]['field']); + } + + public function testNegativeFranchiseOnEditIsRejected(): void + { + [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Supplementary); + $created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [ + 'insurance_id' => $insurance->getId(), + 'coverage_percent' => 80, + ]); + $uuid = $created['data']['data']['uuid']; + + $body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [ + 'franchise_percent' => -5, + ]); + + $this->assertSame(422, $this->responseCode()); + $this->assertSame('franchise_percent', $body['errors'][0]['field']); + } + public function testKindDefaultsToCatalogTypeWhenOmitted(): void { [$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic); @@ -77,7 +108,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase $body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [ 'coverage_percent' => 90, - 'franchise_rials' => 123_000, + 'franchise_percent' => 20, 'annual_ceiling_rials' => null, 'effective_to' => 1_900_000_000, 'kind' => 'supplementary', @@ -85,7 +116,7 @@ class TenantInsuranceContractApiTest extends ApiTestCase $this->assertSame(200, $this->responseCode()); $this->assertEquals(90.0, $body['data']['data']['coverage_percent']); - $this->assertSame(123_000, $body['data']['data']['franchise_rials']); + $this->assertEquals(20.0, $body['data']['data']['franchise_percent']); $this->assertNull($body['data']['data']['annual_ceiling_rials']); $this->assertSame(1_900_000_000, $body['data']['data']['effective_to']); $this->assertSame('supplementary', $body['data']['data']['kind']);