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` رگرسیون جدید ندهد.