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.
This commit is contained in:
@@ -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
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, categories.length)}, 1fr)`, gap: 12 }}>
|
||||
{categories.map((c) => (
|
||||
<div key={c.key} style={field}>
|
||||
<label style={label}>درصد پوشش — {c.label}</label>
|
||||
<input … value={form.categoryPercents[c.key] ?? ''} onChange={(e) => setPercent(c.key, e.target.value)} />
|
||||
…
|
||||
{!isBasic && (
|
||||
<div style={field}>
|
||||
<label style={label}>فرانشیز (تومان)</label>
|
||||
<input … value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
و در `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` (طبق قاعدهٔ پروژه؛ `<select>` بومی ممنوع) کنار فیلتر بیمه،
|
||||
با ذخیره در query string مثل بقیهٔ فیلترها.
|
||||
- ستون جدید بین «بیمار» و «تعداد درخواست»:
|
||||
|
||||
```tsx
|
||||
{ key: 'insurances', header: 'بیمه', render: (r) => (
|
||||
r.insurances.length === 0 ? '—' :
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{r.insurances.map((i) => (
|
||||
<span key={i.insurance_id} className="badge" title={KIND_TITLE[i.kind] ?? i.kind}>
|
||||
{i.insurance_name ?? `#${i.insurance_id}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) },
|
||||
```
|
||||
|
||||
- `searchPlaceholder` به «نام، موبایل، کد ملی بیمار یا نام بیمه» بهروز شود.
|
||||
- `ClaimPatientRow` در `types/index.ts` فیلد `insurances: { insurance_id: number; insurance_name: string | null; kind: string }[]` بگیرد.
|
||||
|
||||
**نحوه تست:** با توکن پزشک سناریو:
|
||||
`GET /api/v1/billing/claims/by-patient?limit=50` → هر ردیف `insurances` غیرخالی؛
|
||||
`?kind=supplementary` → فقط بیماران دارای مطالبهٔ تکمیلی؛ `?search=آسیا` → فقط بیماران آن بیمه؛
|
||||
`?kind=bogus` → ۴۲۲. و مقایسهٔ `meta.totalRecords` با تعداد ردیفها در حالت بدون صفحهبندی.
|
||||
|
||||
### ۶. سناریوی داده + تست دستی برای پزشک 09389388131
|
||||
|
||||
یک Console Command بساز: `src/Insurance/Command/SeedInsuranceScenarioCommand.php`
|
||||
(`app:seed-insurance-scenario`)، با آپشنهای `--doctor-mobile=09389388131` و `--purge`.
|
||||
|
||||
منطق (همه از مسیر سرویسهای واقعی، نه INSERT خام — تا اعداد را همان `BillingCalculator` بسازد):
|
||||
|
||||
1. پزشک را از موبایل پیدا کن (`users.mobile_number` → `doctors.user_id`). نبود پزشک = خطای واضح.
|
||||
محیط: `entity_type='doctor'`, `entity_id = doctors.id` (برای این موبایل: `11548`).
|
||||
2. هر دو نوع خدمت (`outpatient`, `inpatient`) را برای این tenant فعال کن
|
||||
(`TenantServiceCategoryService::save()`).
|
||||
3. یک بیمهٔ پایه (`تامین اجتماعی`, id 176) + سه بیمهٔ تکمیلی از کاتالوگ فعال کن — مثلاً
|
||||
`بیمه ایران` (182)، `بیمه آسیا` (183)، `بیمه دی` (184) — با
|
||||
`TenantInsuranceService::activate()` و درصدهای متفاوت، و برای هر کدام
|
||||
`setCategoryCoverages()` با درصد سرپایی و بستریِ متفاوت:
|
||||
|
||||
| بیمه | نوع | سرپایی | بستری | فرانشیز | سقف سالانه |
|
||||
|---|---|---|---|---|---|
|
||||
| تامین اجتماعی | پایه | ۳۰٪ | ۴۰٪ | — | نامحدود |
|
||||
| بیمه ایران | تکمیلی | ۹۰٪ | ۸۰٪ | ۱۰٪ | ۵۰٬۰۰۰٬۰۰۰ ریال |
|
||||
| بیمه آسیا | تکمیلی | ۷۰٪ | ۶۰٪ | ۲۰٪ | نامحدود |
|
||||
| بیمه دی | تکمیلی | ۱۰۰٪ | ۵۰٪ | ۰٪ | ۱۰٬۰۰۰٬۰۰۰ ریال |
|
||||
|
||||
4. چهار بیمار با موبایل نشاندار `091299000{1..4}` بساز (marker مخصوص همین سناریو تا `--purge`
|
||||
بتواند دقیقاً همانها را پاک کند)، برای هرکدام `PatientRecord` در همین محیط، و مراجعه/صورتحساب
|
||||
با ترکیبهای متفاوت: بیمار۱ فقط پایه، بیمار۲ پایه + بیمه ایران، بیمار۳ فقط بیمه آسیا،
|
||||
بیمار۴ پایه + بیمه دی با خدمتی که سقف را رد میکند (تست سقف).
|
||||
5. صورتحسابها را نهایی کن و با `ClaimService::createFromInvoice()` مطالبه بساز؛ سپس چند مطالبه را
|
||||
با `ClaimService::transition()` جلو ببر: یکی `submitted`، یکی `approved`، یکی `paid`،
|
||||
یکی `rejected` با دلیل — تا هر پنج وضعیتِ فیلتر داده داشته باشد.
|
||||
6. در پایان یک جدول خلاصه در خروجی کنسول چاپ کن: بیمار، بیمه، کل، سهم بیمه، سهم بیمار، وضعیت.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 --purge
|
||||
ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 # بار دوم: بدون دادهی تکراری
|
||||
```
|
||||
|
||||
سپس ورود به پنل با `09389388131` و بررسی دستی:
|
||||
- `/admin/insurance-pricing`: تب «بیمه تکمیلی» سه قرارداد، هر کدام با «سرپایی X٪ · بستری Y٪ · فرانشیز Z٪ · سقف …»؛
|
||||
باز کردن مودال ویرایش → فرانشیز درصدی، و اگر یکی از نوعهای خدمت را غیرفعال کنی آن ورودی محو شود.
|
||||
- `/admin/claims`: ستون «بیمه» پر، فیلتر «نوع بیمه» کار کند، جستجوی «آسیا» فقط بیمار۳ را بدهد،
|
||||
کارتهای آمار با جمع ستونها همخوان باشند.
|
||||
- ورود به جزئیات یک بیمار: سهمها با فرمول فرانشیز درصدی همخوان باشند
|
||||
(`مبلغ اصلی − سهم بیمه = سهم بیمار`).
|
||||
|
||||
اسکرینشات هر دو صفحه را بعد از سناریو ضمیمه کن.
|
||||
|
||||
### ۷. مستندات
|
||||
|
||||
`docs/api/insurance.md` و `docs/api/billing.md` را در همین session بهروز کن:
|
||||
- تغییر فیلد `franchise_rials` → `franchise_percent` در
|
||||
`POST/PATCH /api/v1/billing/tenant-insurances` و
|
||||
`PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage` (با محدودهٔ ۰..۱۰۰).
|
||||
- اجباریبودن `category_coverages` برای نوع خدمتِ فعال + خطای ۴۲۲ مربوطه.
|
||||
- پارامتر جدید `kind` و رفتار تازهٔ `search` در `GET /api/v1/billing/claims/by-patient` و فیلد
|
||||
خروجی `insurances`.
|
||||
- فرمول جدید محاسبهٔ سهم (همان بلوک شبهکد بالا) در بخش محاسبهٔ صورتحساب.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **ترتیب اجرا:** اول migration + مدل + فرمول (وظایف ۱–۲)، بعد بکاندِ اعتبارسنجی (۳)، بعد فرانت (۴–۵)،
|
||||
و سناریو (۶) در آخر — سناریو باید روی کد نهایی اجرا شود تا اعدادش با فرمول جدید ساخته شوند.
|
||||
- `franchise_rials` هیچجا نباید باقی بماند: بعد از پایان کار
|
||||
`grep -rn "franchise_rials" src/ assets/ docs/ migrations/` فقط باید فایل migration جدید و
|
||||
migrationهای تاریخی `Version20260622*.php` را نشان بدهد.
|
||||
- **قاعدهٔ فرانشیز فقط تکمیلی:** در `buildRule()` و در `insuranceShares.ts::ruleOf()` این شرط
|
||||
(`kind === 'supplementary'`) دستنخورده بماند؛ تغییر واحد از ریال به درصد است، نه تغییر دامنهٔ اثر.
|
||||
- **دو واژگان جدا:** قرارداد `kind` = `basic`/`supplementary` ولی مطالبه `insurance_kind` =
|
||||
`base`/`supplementary`. در ClaimsPage از `base` استفاده کن (نه `basic`)؛ این تفاوت واقعی است
|
||||
و در `ClaimPatientDetailPage` هم همینطور نگاشت شده.
|
||||
- **SQL خام و tenant:** کوئریهای `ClaimRepository` زیر `TenantFilter` نیستند؛ افزودن join نباید
|
||||
شرط `c.entity_type/:type` را جابهجا یا مشروط کند.
|
||||
- **الگوها:** کد جدید همان الگوی موجود را نگه دارد — controller نازک، منطق در Service، کوئری در
|
||||
Repository، پاسخها با `$this->success()/paginated()/error()`، تاریخها `int` یونیکس. الگوی طراحیِ
|
||||
تازه لازم نیست؛ `CoverageRule` همچنان همان Value Object است و فقط واحد یکی از فیلدهایش عوض میشود.
|
||||
- **UI:** هیچ طراحی جدیدی نساز — از `Modal`، `SearchableSelect`، `DataTable`، `StatusBadge`، توکنهای
|
||||
`styles.css` و کلاسهای موجود (`badge`, `btn`, `input`, `field-label`) استفاده کن. رشتهها فارسی،
|
||||
اعداد با `formatNumber` و درصد با `٪`.
|
||||
- **بدون تست سبز تمام نیست:** `ddev exec php bin/phpunit` و `yarn test` و
|
||||
`npx tsc --noEmit --project tsconfig.json` هر سه باید پاس شوند، و
|
||||
`ddev exec php vendor/bin/phpstan analyse` رگرسیون جدید ندهد.
|
||||
@@ -8,7 +8,7 @@ import type { ServiceCategoryOption } from '../hooks/useServiceCategories';
|
||||
|
||||
const mkContract = (over: Partial<Contract> = {}): Contract => ({
|
||||
uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
|
||||
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000,
|
||||
version: 1, is_active: true, coverage_percent: 70, franchise_percent: 15,
|
||||
annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000,
|
||||
effective_to: null,
|
||||
category_coverages: { outpatient: 70, inpatient: 30 },
|
||||
@@ -27,11 +27,11 @@ const categories: ServiceCategoryOption[] = [
|
||||
];
|
||||
|
||||
describe('buildInsurancePayload', () => {
|
||||
it('converts toman → rials, per-category percents, and Y-m-d → unix', () => {
|
||||
it('keeps the franchise a percent, converts ceiling toman → rials and Y-m-d → unix', () => {
|
||||
const payload = buildInsurancePayload({
|
||||
...EMPTY_FORM, insuranceId: '3', kind: 'supplementary',
|
||||
categoryPercents: { outpatient: '80', inpatient: '30' },
|
||||
franchise: '50000', ceiling: '2000000',
|
||||
franchise: '10', ceiling: '2000000',
|
||||
effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01',
|
||||
});
|
||||
expect(payload.insurance_id).toBe(3);
|
||||
@@ -41,7 +41,7 @@ describe('buildInsurancePayload', () => {
|
||||
{ key: 'outpatient', coverage_percent: 80 },
|
||||
{ key: 'inpatient', coverage_percent: 30 },
|
||||
]);
|
||||
expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10
|
||||
expect(payload.franchise_percent).toBe(10); // درصد است، نه مبلغ
|
||||
expect(payload.annual_ceiling_rials).toBe(20_000_000);
|
||||
expect(typeof payload.effective_from).toBe('number');
|
||||
expect(payload.effective_to).toBeGreaterThan(payload.effective_from!);
|
||||
@@ -58,10 +58,10 @@ describe('buildInsurancePayload', () => {
|
||||
|
||||
it('forces franchise to zero on a basic contract', () => {
|
||||
const payload = buildInsurancePayload({
|
||||
...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '50000',
|
||||
...EMPTY_FORM, insuranceId: '3', kind: 'basic', franchise: '10',
|
||||
categoryPercents: { outpatient: '70' },
|
||||
});
|
||||
expect(payload.franchise_rials).toBe(0);
|
||||
expect(payload.franchise_percent).toBe(0);
|
||||
});
|
||||
|
||||
it('omits category_coverages when the user may not override them', () => {
|
||||
@@ -75,9 +75,9 @@ describe('buildInsurancePayload', () => {
|
||||
});
|
||||
|
||||
describe('contractToForm', () => {
|
||||
it('maps rials → toman, contract kind, and effective category percents', () => {
|
||||
const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' }));
|
||||
expect(form.franchise).toBe('30000');
|
||||
it('maps the franchise percent, contract kind, and effective category percents', () => {
|
||||
const form = contractToForm(mkContract({ franchise_percent: 30, kind: 'supplementary' }));
|
||||
expect(form.franchise).toBe('30');
|
||||
expect(form.kind).toBe('supplementary');
|
||||
expect(form.categoryPercents).toEqual({ outpatient: '70', inpatient: '30' });
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe('InsuranceModal', () => {
|
||||
expect(screen.getByText('پایه')).toBeInTheDocument();
|
||||
expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
|
||||
expect(screen.getByText('درصد پوشش — خدمات بستری')).toBeInTheDocument();
|
||||
expect(screen.queryByText('فرانشیز (تومان)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('فرانشیز (درصد)')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument();
|
||||
expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
|
||||
});
|
||||
@@ -105,7 +105,48 @@ describe('InsuranceModal', () => {
|
||||
<InsuranceModal open editContract={null} options={options} categories={categories} kind="supplementary" onClose={() => {}} onSubmit={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
|
||||
expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
|
||||
expect(screen.getByText('فرانشیز (درصد)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks submit until every rendered category has a valid percent', () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderWithProviders(
|
||||
<InsuranceModal
|
||||
open
|
||||
editContract={mkContract({ category_coverages: { outpatient: 70 } })}
|
||||
options={options}
|
||||
categories={categories}
|
||||
kind="basic"
|
||||
onClose={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
// بستری بدون درصد مانده: ثبت باید بسته باشد و پیام الزامی دیده شود.
|
||||
expect(screen.getByText('ثبت بیمه')).toBeDisabled();
|
||||
expect(screen.getByText('درصد پوشش الزامی است (۱ تا ۱۰۰)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('درصد پوشش خدمات بستری'), { target: { value: '30' } });
|
||||
expect(screen.getByText('ثبت بیمه')).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByText('ثبت بیمه'));
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders only the service kinds it is given', () => {
|
||||
renderWithProviders(
|
||||
<InsuranceModal
|
||||
open
|
||||
editContract={null}
|
||||
options={options}
|
||||
categories={[{ key: 'outpatient', label: 'خدمات سرپایی' }]}
|
||||
kind="basic"
|
||||
onClose={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('درصد پوشش — خدمات سرپایی')).toBeInTheDocument();
|
||||
expect(screen.queryByText('درصد پوشش — خدمات بستری')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills the percents of an edited contract and marks admin defaults', () => {
|
||||
@@ -132,7 +173,7 @@ describe('InsuranceModal', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByText('ثبت بیمه'));
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
insurance_id: 3, coverage_percent: 70, franchise_rials: 0, kind: 'basic',
|
||||
insurance_id: 3, coverage_percent: 70, franchise_percent: 0, kind: 'basic',
|
||||
category_coverages: [
|
||||
{ key: 'outpatient', coverage_percent: 70 },
|
||||
{ key: 'inpatient', coverage_percent: 30 },
|
||||
|
||||
@@ -22,7 +22,8 @@ export interface Contract {
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
coverage_percent: number;
|
||||
franchise_rials: number;
|
||||
/** درصد، نه مبلغ — سهم اجباری بیمار از مبلغ تحت پوشش تکمیلی. */
|
||||
franchise_percent: number;
|
||||
annual_ceiling_rials: number | null;
|
||||
kind: string | null;
|
||||
effective_from: number;
|
||||
@@ -40,7 +41,7 @@ export interface InsuranceFormValues {
|
||||
effectiveTo: string; // Y-m-d
|
||||
/** درصد پوشش به ازای هر نوع خدمت — کلید = key همان category. */
|
||||
categoryPercents: Record<string, string>;
|
||||
franchise: string; // toman
|
||||
franchise: string; // percent
|
||||
ceiling: string; // toman
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ export function contractToForm(c: Contract): InsuranceFormValues {
|
||||
effectiveFrom: unixToIso(c.effective_from),
|
||||
effectiveTo: unixToIso(c.effective_to),
|
||||
categoryPercents: percentsToStrings(c.category_coverages),
|
||||
franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '',
|
||||
franchise: c.franchise_percent != null ? String(c.franchise_percent) : '',
|
||||
ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '',
|
||||
};
|
||||
}
|
||||
@@ -73,6 +74,12 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
|
||||
return Object.fromEntries(Object.entries(map ?? {}).map(([k, v]) => [k, String(v)]));
|
||||
}
|
||||
|
||||
/** درصد پوششِ قابل ثبت: عددی بین ۱ تا ۱۰۰ — صفر یعنی قرارداد آن نوع خدمت را پوشش نمیدهد. */
|
||||
export function isValidPercent(raw?: string): boolean {
|
||||
const n = Number(raw);
|
||||
return raw !== undefined && raw !== '' && Number.isFinite(n) && n > 0 && n <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the API payload from form values (toman → rials, Y-m-d → unix).
|
||||
* When `doctorUuid` is set, the contract is targeted at that doctor (multi-doctor
|
||||
@@ -80,7 +87,7 @@ function percentsToStrings(map?: Record<string, number>): Record<string, string>
|
||||
*
|
||||
* `category_coverages` is only sent when the user may override percentages — the
|
||||
* backend rejects it otherwise, and omitting it keeps the contract on the central
|
||||
* admin defaults. فرانشیز فقط در قرارداد تکمیلی معنا دارد.
|
||||
* admin defaults. فرانشیز درصد است و فقط در قرارداد تکمیلی معنا دارد.
|
||||
*/
|
||||
export function buildInsurancePayload(
|
||||
v: InsuranceFormValues,
|
||||
@@ -95,7 +102,7 @@ export function buildInsurancePayload(
|
||||
kind: v.kind || null,
|
||||
// ستون قدیمی قرارداد؛ آخرین سطح fallback است و با درصد سرپایی همگام میماند.
|
||||
coverage_percent: Number(v.categoryPercents.outpatient ?? percents[0]?.[1] ?? 0) || 0,
|
||||
franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),
|
||||
franchise_percent: isBasic ? 0 : Number(v.franchise) || 0,
|
||||
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
|
||||
effective_from: isoToUnix(v.effectiveFrom),
|
||||
effective_to: isoToUnix(v.effectiveTo),
|
||||
@@ -111,7 +118,7 @@ interface Props {
|
||||
editContract: Contract | null;
|
||||
/** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */
|
||||
options: InsuranceOption[];
|
||||
/** انواع خدمت از سرور — یک ورودی درصد به ازای هر نوع رندر میشود. */
|
||||
/** نوع خدمتهای *فعالِ* همین tenant — یک ورودی درصد به ازای هر نوع رندر میشود. */
|
||||
categories: ServiceCategoryOption[];
|
||||
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
|
||||
kind: string;
|
||||
@@ -155,8 +162,15 @@ export default function InsuranceModal({
|
||||
const setPercent = (key: string, raw: string) =>
|
||||
setForm((f) => ({ ...f, categoryPercents: { ...f.categoryPercents, [key]: digitsOnly(raw, 3) } }));
|
||||
|
||||
// بدون مجوز update درصدها اصلاً ارسال نمیشوند، پس اجبارشان هم بیمعناست.
|
||||
const missingPercents = canUpdate
|
||||
? categories.filter((c) => !isValidPercent(form.categoryPercents[c.key])).map((c) => c.key)
|
||||
: [];
|
||||
const franchiseInvalid = form.kind === 'supplementary' && form.franchise !== '' && Number(form.franchise) > 100;
|
||||
const canSubmit = !!form.insuranceId && missingPercents.length === 0 && !franchiseInvalid;
|
||||
|
||||
const submit = () => {
|
||||
if (!form.insuranceId) return;
|
||||
if (!canSubmit) return;
|
||||
onSubmit(buildInsurancePayload(form, doctorUuid, canUpdate));
|
||||
};
|
||||
|
||||
@@ -177,7 +191,7 @@ export default function InsuranceModal({
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!form.insuranceId || isPending}
|
||||
disabled={!canSubmit || isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{isPending ? '...' : 'ثبت بیمه'}
|
||||
@@ -230,9 +244,11 @@ export default function InsuranceModal({
|
||||
value={form.categoryPercents[c.key] ?? ''}
|
||||
onChange={(e) => setPercent(c.key, e.target.value)}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
||||
<span style={{ fontSize: 11, color: missingPercents.includes(c.key) ? 'var(--danger)' : 'var(--text-3)' }}>
|
||||
{!canUpdate
|
||||
? 'مقدار پیشفرض تنظیمات مرکزی'
|
||||
: missingPercents.includes(c.key)
|
||||
? 'درصد پوشش الزامی است (۱ تا ۱۰۰)'
|
||||
: sourceOf(c.key) === SOURCE_ADMIN_DEFAULT
|
||||
? 'پیشفرض ادمین'
|
||||
: ' '}
|
||||
@@ -244,8 +260,19 @@ export default function InsuranceModal({
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isBasic ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||
{!isBasic && (
|
||||
<div style={field}>
|
||||
<label style={label}>فرانشیز (تومان)</label>
|
||||
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
|
||||
<label style={label}>فرانشیز (درصد)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="input"
|
||||
aria-label="فرانشیز درصد"
|
||||
value={form.franchise}
|
||||
onChange={(e) => set({ franchise: digitsOnly(e.target.value, 3) })}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: franchiseInvalid ? 'var(--danger)' : 'var(--text-3)' }}>
|
||||
{franchiseInvalid ? 'فرانشیز نمیتواند بیش از ۱۰۰ باشد' : 'سهم بیمار از مبلغ تحت پوشش'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={field}>
|
||||
|
||||
@@ -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<Draft>({ covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
|
||||
// متن خام فیلد درصد جدا از مقدار عددی نگه داشته میشود تا کاربر بتواند فیلد را خالی کند.
|
||||
const [draft, setDraft] = useState<Draft>({ 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
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className="field-label">فرانشیز</label>
|
||||
<PriceInput
|
||||
className="input"
|
||||
style={{ height: 40 }}
|
||||
value={draft.franchise_rials ?? 0}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, franchise_rials: v || null }))}
|
||||
placeholder="۰"
|
||||
min={0}
|
||||
<label className="field-label">فرانشیز (درصد)</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" dir="ltr" className="input"
|
||||
style={{ height: 40, textAlign: 'left' }}
|
||||
aria-label="فرانشیز درصد خدمت"
|
||||
value={franchiseText}
|
||||
placeholder="بدون فرانشیز"
|
||||
onChange={(e) => {
|
||||
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))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -17,7 +17,7 @@ const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const mk = (over: Partial<Contract>): 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('سقف پوشش نامحدود');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
|
||||
<DetailCell label="فرانشیز" value={c.franchise_percent > 0 ? `${formatNumber(c.franchise_percent)}٪` : '—'} />
|
||||
)}
|
||||
<DetailCell label="سقف تعهد سالانه" value={c.annual_ceiling_rials != null ? formatRial(c.annual_ceiling_rials) : 'نامحدود'} />
|
||||
<DetailCell label="تاریخ شروع قرارداد" value={formatDate(c.effective_from)} />
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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: [] }),
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, string> = { 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<string, string>) => {
|
||||
@@ -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<PaginatedResponse<ClaimPatientRow>>({
|
||||
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 ? '—' : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{r.insurances.map((i) => (
|
||||
<span
|
||||
key={`${i.insurance_id}-${i.kind}`}
|
||||
className={`badge ${i.kind === 'supplementary' ? 'violet' : 'blue'}`}
|
||||
title={KIND_LABEL[i.kind ?? ''] ?? i.kind ?? ''}
|
||||
>
|
||||
{i.insurance_name ?? `بیمه #${i.insurance_id}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{ 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}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 130 }}>
|
||||
<label className="field-label">نوع بیمه</label>
|
||||
<SearchableSelect
|
||||
options={KIND_OPTIONS}
|
||||
value={kind}
|
||||
onChange={(v) => setParam({ kind: v ? String(v) : '' })}
|
||||
placeholder="همه انواع"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<label className="field-label">وضعیت</label>
|
||||
<SearchableSelect
|
||||
@@ -225,7 +265,7 @@ export default function ClaimsPage() {
|
||||
loading={listQuery.isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => setParam({ search: v })}
|
||||
searchPlaceholder="نام، موبایل یا کد ملی بیمار"
|
||||
searchPlaceholder="نام، موبایل، کد ملی بیمار یا نام بیمه"
|
||||
emptyMessage="پرونده بیمهای ثبت نشده است."
|
||||
sortKey={sort}
|
||||
sortDir={dir}
|
||||
|
||||
@@ -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
|
||||
) : (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', display: 'inline-flex', gap: 10, whiteSpace: 'nowrap' }}>
|
||||
<span>پوشش {formatNumber(percent)}٪</span>
|
||||
{row?.franchise_rials ? <span>فرانشیز {formatRial(row.franchise_rials)}</span> : null}
|
||||
{row?.franchise_percent ? <span>فرانشیز {formatNumber(row.franchise_percent)}٪</span> : null}
|
||||
{row?.ceiling_rials ? <span>سقف {formatRial(row.ceiling_rials)}</span> : null}
|
||||
{!row && <span className="muted">(ارث از قرارداد)</span>}
|
||||
</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Franchise becomes a percentage instead of an amount.
|
||||
*
|
||||
* A supplementary insurance's franchise is quoted as a share ("فرانشیز ۱۰٪"), not
|
||||
* as a fixed sum, and the old rial column forced every clinic to translate that
|
||||
* share into a number that only held for one price. The stored rial values carry
|
||||
* no percentage inside them and cannot be converted, so they are dropped to zero
|
||||
* — contracts have to restate their franchise once, in percent.
|
||||
*/
|
||||
final class Version20260728180000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Franchise stored as a percentage (tenant_insurances, tenant_service_coverage); old rial values reset to 0/NULL because they are not convertible.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<array{insurance_id: int, insurance_name: string|null, kind: string|null}>
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Insurance\Command;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Claim;
|
||||
use App\Billing\Service\ClaimService;
|
||||
use App\Billing\Service\InvoiceService;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Enum\ServiceCategory;
|
||||
use App\Insurance\Repository\InsuranceRepository;
|
||||
use App\Insurance\Service\TenantInsuranceService;
|
||||
use App\Insurance\Service\TenantServiceCategoryService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* سناریوی قابلاجرای بیمهٔ تکمیلی برای یک پزشک: چند قرارداد با درصد و فرانشیز
|
||||
* متفاوت، چند بیمار که از آنها استفاده میکنند، و مطالبات در وضعیتهای مختلف.
|
||||
*
|
||||
* صورتحسابها از مسیر واقعی InvoiceService/ClaimService ساخته میشوند تا اعداد را
|
||||
* همان BillingCalculator تولید کند — نه INSERT دستی که با فرمول واگرا میشود.
|
||||
*
|
||||
* Marker پاکسازی: موبایل بیمارها با پیشوند 09129900 ساخته میشود.
|
||||
*
|
||||
* ddev exec php bin/console app:seed-insurance-scenario --doctor-mobile=09389388131 --purge
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:seed-insurance-scenario',
|
||||
description: 'Seed supplementary-insurance contracts, patients and claims for one doctor',
|
||||
)]
|
||||
class SeedInsuranceScenarioCommand extends Command
|
||||
{
|
||||
private const PATIENT_MOBILE_PREFIX = '09129900';
|
||||
|
||||
/** نام بیمههای کاتالوگ + درصدها؛ نامها با seed کاتالوگ همخواناند. */
|
||||
private const CONTRACTS = [
|
||||
['name' => 'تامین اجتماعی', '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<string, TenantInsurance> کلید = نام بیمه
|
||||
*/
|
||||
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<string> ردیف جدول خلاصه
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<array{key?: string, coverage_percent?: mixed}> $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<array{key?: string, coverage_percent?: mixed}> $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,12 +335,12 @@ 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,
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<int> $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);
|
||||
|
||||
@@ -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]]);
|
||||
|
||||
@@ -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,7 +152,9 @@ class TenantInsuranceCategoryCoverageApiTest extends ApiTestCase
|
||||
public function testPatchCanDropAnOverrideBackToTheAdminDefault(): void
|
||||
{
|
||||
[$owner, $insurance] = $this->makeDoctorAndInsurance();
|
||||
// سرپایی هم پیشفرض دارد، وگرنه ساخت قرارداد بهخاطر «درصد پوشش الزامی است» رد میشود.
|
||||
static::getContainer()->get(InsuranceCoverageDefaultService::class)->save($insurance->getId(), [
|
||||
['key' => 'outpatient', 'coverage_percent' => 50],
|
||||
['key' => 'inpatient', 'coverage_percent' => 30],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user