Files
clinicpro/.claude/prompt/insurance-franchise-percent-and-claims-insurance.md
T
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

36 KiB
Raw Blame History

بیمهٔ تکمیلی: فرانشیز درصدی، درصد پوشش الزامی برای نوع خدمت فعال، و شناسایی بیمه در مطالبات

پروژه

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_rialsfranchise_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_rialsfranchise_percent
clinicpro/src/Insurance/Entity/TenantServiceCoverage.php همان تغییر در override سطح خدمت
clinicpro/src/Insurance/ValueObject/CoverageRule.php franchiseRialsfranchisePercent
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 (خطوط ۳۱–۴۳) — فرانشیز مبلغ ثابت:

        $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ها رندر می‌شوند، هیچ اجباری روی مقدار نیست، و فرانشیز تومانی است:

        <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():

    franchise_rials: isBasic ? 0 : tomanToRial(Number(v.franchise) || 0),

clinicpro/assets/admin/components/TenantInsuranceContracts.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 سطح بیمار هست و نه جستجو/فیلتر نوع بیمه:

        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_rialsfranchise_percent

TenantInsurance:

    #[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)، چون تبدیل ریال به درصد معنا ندارد:

$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:

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() — مبلغ فرانشیز از پایهٔ «قبل از کسر سهم تکمیلی» ساخته می‌شود:

        $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()) اضافه کن:

        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 یک درصد معتبر آمده باشد:

        $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 می‌شود بگیرد (بدون درخواست جدید):

  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) نشان داده شود:

  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() بفرستد:

      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_rialsfranchise_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 کاتالوگ اضافه شود (برای جستجوی نام بیمه) و در نمای تجمیعی، بیمه‌های هر بیمار جمع شوند:

            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() به آرایهٔ ساخت‌یافته تبدیل شود:

            '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():

        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 را دست نزن (کامنت هشدارِ همان فایل).

ControllerclaimFilters() کلید kind را بگیرد و مقدارش را اعتبارسنجی کند (base | supplementary، در غیر این صورت ERR_VALIDATION_001 با ۴۲۲ و پیام «نوع بیمه نامعتبر است»).

فرانتClaimsPage.tsx:

const KIND_OPTIONS = [
  { value: '', label: 'همه انواع' },
  { value: 'base', label: 'پایه' },
  { value: 'supplementary', label: 'تکمیلی' },
];
  • فیلتر نوع بیمه با SearchableSelect (طبق قاعدهٔ پروژه؛ <select> بومی ممنوع) کنار فیلتر بیمه، با ذخیره در query string مثل بقیهٔ فیلترها.

  • ستون جدید بین «بیمار» و «تعداد درخواست»:

      { 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_numberdoctors.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. در پایان یک جدول خلاصه در خروجی کنسول چاپ کن: بیمار، بیمه، کل، سهم بیمه، سهم بیمار، وضعیت.

نحوه تست:

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