# بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات ## پروژه `clinicpro` (بک‌اند Symfony + پنل ادمین React). تغییرات فقط داخل همین ریپو است؛ کلاینت بیرونی ندارد (`nobat724_front` و `clinic-pro-tauri` هیچ‌کدام `/api/v1/billing/tenant-insurances` یا `/api/v1/billing/claims/*` را مصرف نمی‌کنند — قبل از شروع با grep تأیید کن). ## زمینه مدل بیمهٔ tenant امروز این است: - کاتالوگ بیمه: `insurances` (`type` = `basic` | `supplementary`) — ۶ بیمهٔ پایه و ۱۰ بیمهٔ تکمیلی seed شده‌اند. - قرارداد tenant: `TenantInsurance` (`tenant_insurances`) با `coverage_percent`، `franchise_rials`، `annual_ceiling_rials`، `kind`. - درصد پوشش به تفکیک نوع خدمت: `TenantInsuranceCategoryCoverage` (override قرارداد) → `InsuranceCoverageDefault` (پیش‌فرض مرکزی ادمین) → `coverage_percent` قرارداد (fallback قدیمی). - نوع خدمت: enum `ServiceCategory` = `outpatient` (خدمات سرپایی) / `inpatient` (خدمات بستری). - «کدام نوع خدمت اصلاً بیمه‌ای است» یک سوییچ سراسریِ tenant است: `TenantServiceCategorySetting` (`tenant_service_category_settings`, ردیف نداشته = فعال) و از `GET /api/v1/insurance-pricing` در کلید `service_categories` بیرون می‌آید. - محاسبهٔ سهم: `BillingCalculator::calculateItem()` روی `CoverageRule` — فرانشیز فقط در قرارداد تکمیلی اثر دارد و به‌صورت **مبلغ ریالی** به سهم بیمار اضافه می‌شود. - مطالبات: `claims` با `insurance_id` و `insurance_kind` (`base` | `supplementary` — دقت کن، واژگان مطالبه `base` است نه `basic`). داشبورد سطح‌اول `ClaimsPage` یک ردیف به‌ازای هر **بیمار** می‌دهد. سه ایراد واقعی در همین مسیر وجود دارد: 1. مودال «افزودن بیمه» ورودی «درصد پوشش» را برای **هر دو** نوع خدمت رندر می‌کند، بی‌توجه به این‌که tenant کدام نوع را فعال کرده، و خالی‌ماندنِ آن‌ها هیچ خطایی نمی‌دهد → قرارداد با پوشش صفر ثبت می‌شود. 2. فرانشیز به‌صورت «تومان» گرفته و ذخیره می‌شود (`franchise_rials`) در حالی که فرانشیز در بیمهٔ تکمیلی یک **درصد** است. 3. در داشبورد مطالبات (سطح اول) هیچ‌جا معلوم نیست هر ردیف مربوط به کدام بیمه است، و نه فیلتر «نوع بیمه» دارد و نه جستجو روی نام بیمه کار می‌کند. (سطح دومِ `ClaimPatientDetailPage` نام و نوع بیمه را دارد.) ## مشکل / هدف ۱. **درصد پوشش فقط برای نوع خدمتِ فعال، و الزامی.** در مودال افزودن/ویرایش بیمه، به‌جای «همهٔ ServiceCategoryها»، فقط نوع‌های خدمتی رندر شوند که برای همان tenant فعال‌اند (`service_categories[].enabled === true` از `GET /api/v1/insurance-pricing`)، و ثبت فرم بدون درصدِ معتبر (۱ تا ۱۰۰) برای هر نوع خدمتِ رندرشده مجاز نباشد. بک‌اند هم همین را اجبار کند (نه فقط UI). ۲. **فرانشیز درصد است، نه تومان.** `franchise_rials` → `franchise_percent` در هر دو سطح (قرارداد `TenantInsurance` و override خدمت `TenantServiceCoverage`)، در `CoverageRule`، در `BillingCalculator`، در API، در پنل، و در آینهٔ سمت‌کلاینت `assets/admin/lib/insuranceShares.ts`. **معنای انتخاب‌شده (این را در کد کامنت کن):** فرانشیز درصدی است از مبلغی که بیمهٔ تکمیلی روی آن کار می‌کند — یعنی «باقیماندهٔ بعد از بیمهٔ پایه» — و سهم بیمار است: ``` remainingAfterBase = total − baseShare(سقف‌خورده) suppShare = remainingAfterBase × coveragePercent٪ (سقف‌خورده) franchiseAmount = remainingAfterBase × franchisePercent٪ ← جدید patient = min(total, remainingAfterBase − suppShare + franchiseAmount) ``` این با واقعیت بازار می‌خواند («تکمیلی ۹۰٪ می‌دهد، فرانشیز ۱۰٪») و ساختار فعلی فرمول را حفظ می‌کند؛ تنها تفاوت این است که مبلغ فرانشیز به‌جای عدد ثابت، از درصد ساخته می‌شود. فرانشیز در بیمهٔ **پایه** همچنان بی‌اثر است. ۳. **مطالبات: کدام بیمه.** در `ClaimsPage` (سطح اول) ستون «بیمه» اضافه شود (یک بیمار می‌تواند مطالبه زیر چند بیمه داشته باشد → لیست بیمه‌های آن بیمار با نوعشان)، فیلتر «نوع بیمه» (پایه/تکمیلی) اضافه شود، و جستجوی متنی علاوه بر نام/موبایل/کد ملی بیمار، **نام بیمه** را هم پوشش دهد. ۴. **سناریوی واقعی + تست.** برای پزشک `09389388131` (کاربر `users.id = 11685`، پزشک `doctors.id = 11548`، `entity_type='doctor'`, `entity_id=11548`) چند بیمهٔ تکمیلی و چند بیمار ساخته شود که از این بیمه‌ها استفاده می‌کنند، مطالبات تولید و مسیر وضعیت‌ها تست شود. ## معیار پذیرش **درصد پوشش / نوع خدمت فعال** - ✅ موفق: tenantـی که فقط `outpatient` را فعال دارد، مودال «افزودن بیمه» تنها یک ورودی «درصد پوشش — خدمات سرپایی» نشان می‌دهد؛ با مقدار ۷۰ ثبت می‌شود و `GET /api/v1/billing/tenant-insurances` برای آن قرارداد `category_coverages.outpatient = 70` می‌دهد. - ❌ خطا: `POST /api/v1/billing/tenant-insurances` با `category_coverages: [{key:'outpatient', coverage_percent: null}]` در حالی که `outpatient` فعال است → HTTP 422 با `{ success:false, errors:[{ code:'ERR_VALIDATION_001', field:'category_coverages', message:'درصد پوشش خدمات سرپایی الزامی است' }] }`. دکمهٔ «ثبت بیمه» هم در UI تا پرشدن همهٔ درصدهای فعال غیرفعال است. - ⚠️ مرزی: نوع خدمتی که در تنظیمات tenant **غیرفعال** است نه رندر می‌شود و نه در payload می‌رود؛ قراردادی که از قبل برای آن نوع override داشت، آن override دست‌نخورده در DB می‌ماند (ارسال‌نشدن ≠ حذف؛ حذف فقط با `coverage_percent: null` صریح انجام می‌شود). **فرانشیز درصدی** - ✅ موفق: قرارداد تکمیلی با `coverage_percent = 90` و `franchise_percent = 10` روی خدمتی ۱٬۰۰۰٬۰۰۰ ریالی بدون بیمهٔ پایه → `supplementary = 900,000`، `patient = 100,000 + 100,000 = 200,000`، و `total = base + supp + patient` همچنان برقرار است (تست موجود `tests/Patient/SessionInsuranceShareTest::testTheBreakdownAlwaysSumsBackToTheGrossTotal` باید سبز بماند). - ❌ خطا: `franchise_percent: 150` → HTTP 422 «فرانشیز باید بین ۰ تا ۱۰۰ باشد» (`field: 'franchise_percent'`). همچنین `franchise_percent` منفی → همان خطا. - ⚠️ مرزی: `franchise_percent = 100` روی قرارداد تکمیلی → سهم بیمار از `total` بیشتر نمی‌شود (`min(total)` نگه‌دارنده است) و سهم بیمه منفی نمی‌شود؛ در بیمهٔ **پایه** هر مقدار فرانشیز بی‌اثر است. **مطالبات** - ✅ موفق: `GET /api/v1/billing/claims/by-patient` برای بیماری با دو مطالبه زیر دو بیمه، آرایهٔ `insurances: [{insurance_id, insurance_name, kind}, …]` می‌دهد و ستون «بیمه» در جدول هر دو را نشان می‌دهد. - ❌ خطا: `?kind=bogus` → HTTP 422 «نوع بیمه نامعتبر است» (نه ۵۰۰ و نه لیست خالیِ بی‌صدا). - ⚠️ مرزی: `?search=آسیا` فقط بیمارانی را برمی‌گرداند که مطالبه‌ای زیر «بیمه آسیا» دارند، و `meta.totalRecords` با همان فیلتر هم‌خوان است (شمارش و لیست از یک WHERE می‌آیند)؛ `?kind=supplementary&insurance_id=<یک بیمهٔ پایه>` → صفر ردیف، بدون خطا. **سناریو** - ✅ موفق: بعد از اجرای دستور سناریو، ورود به پنل با `09389388131` → صفحهٔ «پرونده‌های بیمه» حداقل ۴ بیمار با مطالبات زیر ۳ بیمهٔ تکمیلی مختلف نشان می‌دهد؛ کارت‌های آمار (ادعا/وصول/مانده) با جمع ستون‌های جدول هم‌خوان‌اند. - ⚠️ مرزی: اجرای دوبارهٔ دستور، داده را دوباره نمی‌سازد (idempotent) یا با `--purge` تمیز و بازسازی می‌کند. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `clinicpro/src/Insurance/Entity/TenantInsurance.php` | ستون `franchise_rials` → `franchise_percent` | | `clinicpro/src/Insurance/Entity/TenantServiceCoverage.php` | همان تغییر در override سطح خدمت | | `clinicpro/src/Insurance/ValueObject/CoverageRule.php` | `franchiseRials` → `franchisePercent` | | `clinicpro/src/Insurance/Service/TenantInsuranceService.php` | `activate()`، `buildRule()`، `setServiceCoverage()`، اعتبارسنجی درصد | | `clinicpro/src/Insurance/Controller/InsuranceController.php` | خواندن/نوشتن `franchise_percent`، اجبار درصد برای نوع خدمت فعال (خطوط ۴۷۶–۵۹۲ و ۶۴۷–۶۹۳) | | `clinicpro/src/Billing/Service/BillingCalculator.php` | فرمول فرانشیز درصدی | | `clinicpro/src/Billing/Repository/ClaimRepository.php` | `patientAggregateSql()` + `patientAggregateFilters()` — بیمه‌ها، فیلتر `kind`، جستجوی نام بیمه | | `clinicpro/src/Billing/Controller/BillingController.php` | `claimsByPatient()`، `claimFilters()` — پارامتر `kind` و نگاشت نام بیمه | | `clinicpro/assets/admin/components/InsuranceModal.tsx` | ورودی فرانشیز درصدی + درصد الزامی + فقط نوع خدمت فعال | | `clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` | تغذیهٔ مودال با نوع خدمتِ فعال، خلاصه و جزئیات فرانشیز درصدی | | `clinicpro/assets/admin/lib/insuranceShares.ts` | آینهٔ فرمول سمت کلاینت | | `clinicpro/assets/admin/components/session/CreateStep.tsx` | مصرف‌کنندهٔ `franchise_rials` در پذیرش | | `clinicpro/assets/admin/components/ServiceInsuranceModal.tsx`، `pages/ServiceDetailPage.tsx` | override فرانشیز سطح خدمت | | `clinicpro/assets/admin/pages/ClaimsPage.tsx` | ستون بیمه + فیلتر نوع بیمه | | `clinicpro/assets/admin/types/index.ts` | `ClaimPatientRow` | | `clinicpro/migrations/` | migration تغییر نام و نوع ستون‌ها | | `clinicpro/docs/api/insurance.md`، `docs/api/billing.md` | مستندسازی قرارداد جدید | ## وضعیت فعلی `clinicpro/src/Billing/Service/BillingCalculator.php` (خطوط ۳۱–۴۳) — فرانشیز مبلغ ثابت: ```php $suppShare = Money::zero(); if ($supplementary !== null && $supplementary->covered) { $suppShare = $remaining->percent($supplementary->coveragePercent); if ($supplementary->ceilingRials !== null) { $suppShare = $suppShare->min(new Money($supplementary->ceilingRials)); } $remaining = $remaining->sub($suppShare); } // بیمهٔ پایه صرفاً درصدی است: سهم بیمار = کل − سهم پایه. فرانشیز فقط در بیمهٔ // تکمیلی معنا دارد و سهم بیمار را از کل بیشتر نمی‌کند. $franchise = new Money($supplementary?->franchiseRials ?? 0); $patient = $remaining->add($franchise)->min($total); ``` `clinicpro/assets/admin/components/InsuranceModal.tsx` (خطوط ۲۱۹–۲۵۵) — همهٔ categoryها رندر می‌شوند، هیچ اجباری روی مقدار نیست، و فرانشیز تومانی است: ```tsx
{categories.map((c) => (
setPercent(c.key, e.target.value)} /> … {!isBasic && (
set({ franchise: digitsOnly(e.target.value) })} />
)} ``` و در `buildInsurancePayload()`: ```ts franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0), ``` `clinicpro/assets/admin/components/TenantInsuranceContracts.tsx` — مودال از لیست سراسری تغذیه می‌شود و فرانشیز ریالی نمایش داده می‌شود: ```tsx const { categories } = useServiceCategories(); // همهٔ نوع‌ها، نه نوع‌های فعالِ tenant … if (c.insurance_kind === 'supplementary' && c.franchise_rials > 0) { parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`); } ``` `clinicpro/src/Billing/Repository/ClaimRepository.php` — نه بیمه‌ای در SELECT سطح بیمار هست و نه جستجو/فیلتر نوع بیمه: ```php if (!empty($filters['search'])) { $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search OR u.national_code LIKE :search)'; $params['search'] = '%' . trim((string) $filters['search']) . '%'; } ``` ## وظایف ### ۱. مهاجرت داده: `franchise_rials` → `franchise_percent` `TenantInsurance`: ```php #[ORM\Column(name: 'franchise_percent', type: 'decimal', precision: 5, scale: 2)] private string $franchisePercent = '0.00'; public function getFranchisePercent(): float { return (float) $this->franchisePercent; } public function setFranchisePercent(float $v): self { $this->franchisePercent = (string) $v; $this->updatedAt = time(); return $this; } ``` `TenantServiceCoverage`: همان تغییر با `nullable: true` و getter/setter `?float`. هر دو `toArray()` کلید `franchise_percent` بدهند (کلید `franchise_rials` کاملاً حذف شود — سازگاری عقب‌رو لازم نیست چون تنها مصرف‌کننده پنل خودِ همین ریپوست). Migration دستی بنویس (نه فقط `diff`)، چون تبدیل ریال به درصد معنا ندارد: ```php $this->addSql('ALTER TABLE tenant_insurances CHANGE franchise_rials franchise_percent DECIMAL(5,2) NOT NULL DEFAULT 0'); $this->addSql('UPDATE tenant_insurances SET franchise_percent = 0'); $this->addSql('ALTER TABLE tenant_service_coverage CHANGE franchise_rials franchise_percent DECIMAL(5,2) DEFAULT NULL'); $this->addSql('UPDATE tenant_service_coverage SET franchise_percent = NULL'); ``` در `getDescription()` صریح بنویس که مقادیر ریالیِ قبلی قابل تبدیل نیستند و صفر می‌شوند. **نحوه تست:** `ddev exec php bin/console doctrine:migrations:migrate --no-interaction` سپس `ddev exec php bin/console doctrine:schema:validate` باید mapping را سبز بدهد. ### ۲. فرمول فرانشیز درصدی `CoverageRule`: ```php final readonly class CoverageRule { public function __construct( public float $coveragePercent, /** درصدِ سهم بیمار از «باقیماندهٔ بعد از بیمهٔ پایه»؛ فقط در قرارداد تکمیلی معنا دارد. */ public float $franchisePercent, public ?int $ceilingRials, public bool $covered = true, ) {} public static function notCovered(): self { return new self(0.0, 0.0, null, false); } } ``` `BillingCalculator::calculateItem()` — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته می‌شود: ```php $suppBase = $remaining; // باقیماندهٔ بعد از بیمهٔ پایه $suppShare = Money::zero(); if ($supplementary !== null && $supplementary->covered) { $suppShare = $suppBase->percent($supplementary->coveragePercent); if ($supplementary->ceilingRials !== null) { $suppShare = $suppShare->min(new Money($supplementary->ceilingRials)); } $remaining = $suppBase->sub($suppShare); } // فرانشیز درصدی است، نه مبلغ: سهمِ بیمار از همان مبلغی که تکمیلی رویش کار می‌کند. $franchise = $supplementary !== null && $supplementary->covered ? $suppBase->percent($supplementary->franchisePercent) : Money::zero(); $patient = $remaining->add($franchise)->min($total); ``` `TenantInsuranceService::buildRule()` و `activate()` و `setServiceCoverage()` را به `franchisePercent`/`franchise_percent` ببر و اعتبارسنجی ۰..۱۰۰ را (همان الگوی `setCategoryCoverages()`) اضافه کن: ```php if ($franchisePercent < 0 || $franchisePercent > 100) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فرانشیز باید بین ۰ تا ۱۰۰ باشد', 422, 'franchise_percent'); } ``` `InsuranceController` هر سه نقطهٔ `franchise_rials` (خطوط ۵۰۲، ۵۶۴–۵۶۵، ۶۸۵) را با `franchise_percent` و cast به `float` جایگزین کند. **نحوه تست:** یک تست واحد در `tests/Billing/` برای `BillingCalculator` با سه سناریو: (الف) پایه ۳۰٪ + تکمیلی ۹۰٪ + فرانشیز ۱۰٪ روی ۱٬۰۰۰٬۰۰۰ ریال، (ب) فرانشیز ۱۰۰٪، (ج) بیمهٔ پایه با فرانشیز غیرصفر (باید بی‌اثر بماند). در هر سه، `total === base + supp + patient` را assert کن. علاوه بر آن: `ddev exec php bin/phpunit tests/Patient/SessionInsuranceShareTest.php` باید سبز بماند (مقادیر انتظاری‌اش را طبق فرمول جدید به‌روز کن، نه با تغییر فرمول). ### ۳. الزامی‌کردن درصد پوشش برای نوع خدمتِ فعال **بک‌اند** — در `InsuranceController::applyCategoryCoverages()` قبل از فراخوانی سرویس، بررسی کن که برای هر نوع خدمتِ فعالِ همان tenant یک درصد معتبر آمده باشد: ```php $enabled = $this->serviceCategories->enabledKeys($entityType, $entityId); $sent = []; foreach ($data['category_coverages'] ?? [] as $row) { $sent[(string) ($row['key'] ?? '')] = $row['coverage_percent'] ?? null; } foreach ($enabled as $key) { if (($sent[$key] ?? null) === null || $sent[$key] === '' || (float) $sent[$key] <= 0) { return $this->error( ErrorCodes::ERR_VALIDATION_001, sprintf('درصد پوشش %s الزامی است', ServiceCategory::from($key)->label()), 422, 'category_coverages', ); } } ``` توجه: `applyCategoryCoverages()` امروز `$entityType/$entityId` ندارد — امضایش را گسترش بده (هر دو فراخوان `activate` و `update` این مقادیر را در دست دارند). این چک فقط وقتی اجرا شود که کلید `category_coverages` در payload آمده باشد؛ PATCHـهای دیگر (مثل toggle وضعیت) نباید بشکنند. **فرانت** — `TenantInsuranceContracts.tsx` به‌جای `useServiceCategories()` نوع خدمتِ **فعالِ tenant** را از همان `pricingQuery` که از قبل fetch می‌شود بگیرد (بدون درخواست جدید): ```tsx const enabledCategories: ServiceCategoryOption[] = useMemo(() => ( ((pricingQuery.data as any)?.data?.service_categories ?? []) .filter((c: { enabled: boolean }) => c.enabled) .map((c: { key: string; label: string }) => ({ key: c.key, label: c.label })) ), [pricingQuery.data]); ``` و همین را به `categories` مودال بدهد. `useServiceCategories` اگر مصرف‌کنندهٔ دیگری ندارد دست‌نخورده بماند (فرم خدمت از آن استفاده می‌کند — قبل از حذف grep کن). `InsuranceModal.tsx`: دکمهٔ ثبت تا وقتی هر نوع خدمتِ رندرشده مقدار ۱..۱۰۰ نداشته باشد `disabled` بماند، و زیر ورودیِ خالی پیام «الزامی است» با `var(--danger)` نشان داده شود: ```tsx const percentsValid = categories.every((c) => { const n = Number(form.categoryPercents[c.key]); return Number.isFinite(n) && n > 0 && n <= 100; }); … disabled={!form.insuranceId || !percentsValid || isPending} ``` (وقتی `canUpdate === false` درصدها فقط‌خواندنی و ارسال‌نشدنی‌اند — در آن حالت این اجبار اعمال نشود.) **نحوه تست:** - API: با توکن پزشک `09389388131`، `PUT /api/v1/insurance-pricing` با `service_categories: [{key:'outpatient',enabled:true},{key:'inpatient',enabled:false}]`، سپس `POST /api/v1/billing/tenant-insurances` یک‌بار بدون درصد (انتظار ۴۲۲) و یک‌بار با `[{key:'outpatient',coverage_percent:70}]` (انتظار ۲۰۱). - UI: `assets/admin/components/InsuranceModal.test.tsx` را با کیس «ثبت غیرفعال است تا درصد پر شود» و «فقط نوع خدمتِ فعال رندر می‌شود» گسترش بده؛ `yarn test`. ### ۴. فرانشیز درصدی در پنل - `InsuranceModal.tsx`: برچسب «فرانشیز (درصد)»، ورودی `digitsOnly(value, 3)` با اعتبارسنجی ≤۱۰۰، `contractToForm()` از `c.franchise_percent` بخواند (بدون `rialToToman`)، و `buildInsurancePayload()` بفرستد: ```ts franchise_percent: isBasic ? 0 : Number(v.franchise) || 0, ``` `Contract` و `InsuranceFormValues` هم به‌روز شوند (`franchise_percent: number`). - `TenantInsuranceContracts.tsx`: `contractSummary()` → ``فرانشیز ${formatNumber(c.franchise_percent)}٪`` و `ContractDetails` → مقدار `٪`دار. - `insuranceShares.ts`: `CoverageRule.franchise` به معنی درصد شود و `patientShareOf()` دقیقاً آینهٔ فرمول جدید سرور باشد (پایهٔ فرانشیز = باقیماندهٔ بعد از پایه). این فایل تنها منبع محاسبه در پنل است؛ هیچ صفحه‌ای فرمول موازی ننویسد. - `CreateStep.tsx`، `ServiceInsuranceModal.tsx`، `ServiceDetailPage.tsx`: `franchise_rials` → `franchise_percent`؛ در `ServiceInsuranceModal` ورودی از `PriceInput` به ورودی درصد تغییر کند و در `ServiceDetailPage` نمایش `فرانشیز {formatRial(...)}` به `فرانشیز {formatNumber(...)}٪` تبدیل شود. **نحوه تست:** `npx tsc --noEmit --project tsconfig.json` (هیچ ارجاع باقیمانده‌ای به `franchise_rials` نباید بماند — با grep هم تأیید کن) و `yarn test` (فایل‌های تستِ متأثر: `InsuranceModal.test.tsx`, `TenantInsuranceContracts.test.tsx`, `session/CreateStep.test.tsx`, `appointments/ConfirmAppointmentModal.test.tsx`, `appointments/TurnsTimeline.test.tsx`, `pages/AppointmentEditPage.test.tsx`). ### ۵. بیمه در داشبورد مطالبات **Repository** — در `patientAggregateSql()` و `detailsForPatient()` هر دو، join کاتالوگ اضافه شود (برای جستجوی نام بیمه) و در نمای تجمیعی، بیمه‌های هر بیمار جمع شوند: ```sql LEFT JOIN insurances ins ON ins.id = c.insurance_id … GROUP_CONCAT(DISTINCT CONCAT_WS('|', c.insurance_id, ins.name, c.insurance_kind) SEPARATOR '~') AS insurances, ``` سپس در `aggregateByPatient()` به آرایهٔ ساخت‌یافته تبدیل شود: ```php 'insurances' => array_values(array_filter(array_map(static function (string $chunk): ?array { [$id, $name, $kind] = array_pad(explode('|', $chunk), 3, null); return $id === null || $id === '' ? null : [ 'insurance_id' => (int) $id, 'insurance_name' => $name, 'kind' => $kind, // base | supplementary ]; }, explode('~', (string) $r['insurances'])))), ``` در `patientAggregateFilters()`: ```php if (!empty($filters['kind'])) { $conditions[] = 'c.insurance_kind = :kind'; $params['kind'] = $filters['kind']; } if (!empty($filters['search'])) { $conditions[] = '(u.real_name LIKE :search OR u.mobile_number LIKE :search' . ' OR u.national_code LIKE :search OR ins.name LIKE :search)'; $params['search'] = '%' . trim((string) $filters['search']) . '%'; } ``` ⚠️ همان WHERE در `countPatientsWithClaims()` و `detailsForPatient()` هم استفاده می‌شود؛ join جدید باید در **هر سه** SQL باشد وگرنه شمارش با لیست واگرا می‌شود یا کوئری با «Unknown column ins.name» می‌شکند. شرط محیطِ `c.entity_type = :type AND c.entity_id = :id` را دست نزن (کامنت هشدارِ همان فایل). **Controller** — `claimFilters()` کلید `kind` را بگیرد و مقدارش را اعتبارسنجی کند (`base` | `supplementary`، در غیر این صورت `ERR_VALIDATION_001` با ۴۲۲ و پیام «نوع بیمه نامعتبر است»). **فرانت** — `ClaimsPage.tsx`: ```tsx const KIND_OPTIONS = [ { value: '', label: 'همه انواع' }, { value: 'base', label: 'پایه' }, { value: 'supplementary', label: 'تکمیلی' }, ]; ``` - فیلتر نوع بیمه با `SearchableSelect` (طبق قاعدهٔ پروژه؛ `