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:
hamed
2026-07-29 13:28:59 +03:30
parent 11b4dcdd34
commit 4f4bce9fe2
31 changed files with 1497 additions and 137 deletions
@@ -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` رگرسیون جدید ندهد.
+53 -12
View File
@@ -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 },
+38 -11
View File
@@ -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,
};
};
+11 -8
View File
@@ -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 },
};
+43 -3
View File
@@ -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}
+2 -2
View File
@@ -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>
+8
View File
@@ -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;
+44
View File
@@ -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,
+41 -1
View File
@@ -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;
+9 -7
View File
@@ -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,
);
+6 -5
View File
@@ -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 -3
View File
@@ -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',
];
+52 -13
View File
@@ -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);
+71 -3
View File
@@ -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']);