Files
clinicpro/.claude/prompt/insurance-franchise-percent-and-claims-insurance.md
hamed 4f4bce9fe2 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.
2026-07-29 13:28:59 +03:30

543 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات
## پروژه
`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` رگرسیون جدید ندهد.