Files
clinicpro/.claude/prompt/service-insurance-per-insurer-coverage.md
hamed 27840da78d feat: integrate insurance coverage management for clinic services
- Updated NewSessionPage to calculate patient share based on insurance coverage rules.
- Refactored billing calculations to utilize new patientShareOf function for service items.
- Enhanced API documentation to reflect changes in service coverage structure.
- Implemented ServiceInsuranceModal for managing insurance coverage per service.
- Added UI components for displaying and editing insurance coverage details.
- Removed obsolete toggle switch styles and adjusted CSS for new components.
- Ensured backend endpoints support both service_item_id and service_item_uuid for flexibility.
2026-06-24 04:23:50 +03:30

14 KiB
Raw Permalink Blame History

اتصال «بیمه‌ی سرویس» در صفحه سرویس‌های کلینیک به سامانه‌ی واقعی پوشش بیمه و مطالبات

پروژه

clinicpro (backend Symfony + admin React)

زمینه

صفحه https://clinic-pro.ddev.site/admin/clinic-services هنگام تعریف سرویس فقط دو فیلد ساده‌ی روی خود ServiceItem را ست می‌کند (این بخش قبلاً با پرامپت service-insurance-coverage.md اضافه شده):

  • insurance_covered (boolean) — «این خدمت شامل بیمه می‌شود»
  • insurance_price_rials (int, nullable) — «قیمت با بیمه»

اما سامانه‌ی واقعی بیمه و مطالبات در پروژه چیز دیگری است و به این دو فیلد وصل نیست:

  • App\Insurance\Entity\TenantInsurance — قرارداد بیمه‌ی فعال هر مطب/کلینیک با یک بیمه‌گر (پایه یا تکمیلی)، دارای coverage_percent، franchise_rials، annual_ceiling_rials.
  • App\Insurance\Entity\TenantServiceCoverage — قاعده‌ی پوشش به ازای هر بیمه‌گر × هر سرویس (covered, coverage_percent, franchise_rials, ceiling_rials). همین جدول است که محاسبه‌ی سهم را تعیین می‌کند.
  • App\Insurance\ValueObject\CoverageRule + App\Billing\Service\BillingCalculator — سهم بیمه‌ی پایه/مکمل و سهم بیمار را از روی همین قاعده محاسبه می‌کنند.
  • App\Billing\Service\ClaimService::createFromInvoice()مطالبات بیمه را از Invoice می‌سازد؛ هر ClaimItem فقط وقتی ساخته می‌شود که InvoiceItem سهم بیمه‌ی مثبت داشته باشد (getBaseInsuranceRials() / getSupplementaryRials()).

نتیجه: سرویسی که در صفحه‌ی clinic-services «شامل بیمه» علامت خورده، هیچ ردیفی در TenantServiceCoverage ندارد، پس در BillingCalculator سهم بیمه‌اش صفر می‌شود و در ClaimService هیچ ClaimItem نمی‌سازد ⇒ در مطالبات بیمه ظاهر نمی‌شود. این همان شکافی است که این پرامپت می‌بندد.

بافت ایران: بیمه‌گرها دو دسته‌اند — پایه (تأمین اجتماعی، سلامت، نیروهای مسلح، …) و تکمیلی/مکمل (دانا، آسیا، …). هر سرویس می‌تواند با هر بیمه‌گرِ فعالِ مطب درصد پوشش، فرانشیز و سقف متفاوت داشته باشد. مدل تک‌فیلدیِ insurance_price_rials این تنوع را پوشش نمی‌دهد؛ منبعِ حقیقت باید TenantServiceCoverage باشد.

مشکل / هدف

هنگام ویرایش یک سرویس در صفحه‌ی clinic-services، کاربر باید بتواند پوشش بیمه‌ی آن سرویس را به ازای هر بیمه‌گرِ فعال مطب تعریف کند (covered + درصد پوشش + فرانشیز + سقف). این داده باید در TenantServiceCoverage نوشته شود تا مستقیماً وارد چرخه‌ی BillingCalculator → Invoice → ClaimService (مطالبات بیمه) شود.

سه endpoint از قبل وجود دارند و باید بازاستفاده شوند (نه ساختن endpoint جدید):

  • GET /api/v1/billing/tenant-insurances → لیست قراردادهای بیمه‌ی فعال مطب.
  • GET /api/v1/billing/tenant-insurances/{uuid}/service-coverage → پوشش‌های یک قرارداد.
  • PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage → ست‌کردن پوشش یک سرویس برای آن قرارداد.

فایل‌های مرتبط

فایل نقش
assets/admin/pages/ClinicServicesPage.tsx فرم ویرایش سرویس — محل افزودن بخش پوشش بیمه
assets/admin/components/ServiceTariffModal.tsx الگوی موجود modalِ مرتبط با سرویس (برای سبک UI ردیف‌ها)
src/Insurance/Controller/InsuranceController.php endpointهای tenant-insurances و service-coverage (خطوط ۲۹۹–۴۳۵)
src/Insurance/Service/TenantInsuranceService.php setServiceCoverage(...) که ردیف TenantServiceCoverage را upsert می‌کند
src/Insurance/Entity/TenantServiceCoverage.php موجودیت قاعده‌ی پوشش به‌ازای بیمه‌گر×سرویس
src/Insurance/Entity/TenantInsurance.php قرارداد بیمه‌ی فعال مطب
src/Billing/Service/BillingCalculator.php محاسبه‌ی سهم از روی CoverageRule
src/Billing/Service/ClaimService.php ساخت مطالبات از Invoice (مصرف‌کننده‌ی نهاییِ این داده)
src/ClinicService/Entity/ServiceItem.php فیلدهای ساده‌ی insurance_covered / insurance_price_rials و toArray() (فقط uuid برمی‌گرداند)
docs/api/insurance.md مستند API که باید به‌روز شود

وضعیت فعلی

فرم سرویس (frontend) — فقط دو فیلد ساده

assets/admin/pages/ClinicServicesPage.tsx خطوط ۳۹۷–۴۱۵:

<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13.5 }}>
  <input
    type="checkbox"
    checked={itemForm.watch('insurance_covered') ?? false}
    onChange={(e) => itemForm.setValue('insurance_covered', e.target.checked)}
  />
  این خدمت شامل بیمه می‌شود
</label>
{itemForm.watch('insurance_covered') && (
  <div className="field">
    <label>قیمت با بیمه (ریال)</label>
    <PriceInput
      value={itemForm.watch('insurance_price_rials') ?? 0}
      onChange={(v) => itemForm.setValue('insurance_price_rials', v)}
      placeholder="سهم بیمار با بیمه"
      min={0}
    />
  </div>
)}

این مقادیر فقط روی ServiceItem ذخیره می‌شوند و در BillingCalculator/ClaimService خوانده نمی‌شوند.

endpoint موجود ست‌کردن پوشش (backend) — payload مورد انتظار

src/Insurance/Controller/InsuranceController.php خط ۴۰۹:

#[Route('/api/v1/billing/tenant-insurances/{uuid}/service-coverage', methods: ['PUT'])]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
    // ... resolveEntity + بررسی مالکیت قرارداد ...
    $serviceItemId = (int) ($data['service_item_id'] ?? 0); // الزامی
    $this->tenantInsuranceService->setServiceCoverage(
        $contract,
        $serviceItemId,
        (bool)  ($data['covered']          ?? true),
        isset($data['coverage_percent']) ? (float) $data['coverage_percent'] : null,
        isset($data['franchise_rials'])  ? (int)   $data['franchise_rials']  : null,
        isset($data['ceiling_rials'])    ? (int)   $data['ceiling_rials']    : null,
    );
    return $this->success(['message' => 'پوشش خدمت ذخیره شد']);
}

توجه: endpoint با service_item_id (id داخلی) کار می‌کند، اما frontend فقط uuid سرویس دارد — ServiceItem::toArray() فقط uuid می‌دهد. وظیفه ۳ این شکاف را حل می‌کند.

موجودیت پوشش — قاعده‌ای که محاسبه را تعیین می‌کند

TenantServiceCoverage: tenant_insurance_id، service_item_id، covered، coverage_percent (decimal 5,2)، franchise_rials، ceiling_rials؛ یکتا روی (tenant_insurance_id, service_item_id).

وظایف

۱. افزودن «پوشش بیمه به ازای بیمه‌گر» به modal سرویس (frontend)

در ClinicServicesPage.tsx، داخل modal سرویس (بعد از فیلد «پرسنل مسئول»)، یک بخش جدید اضافه کن:

  • با useQuery لیست قراردادهای فعال بیمه را از GET /api/v1/billing/tenant-insurances بگیر (نمایش نام بیمه‌گر + برچسب نوع پایه/تکمیلی).
  • این بخش فقط در حالت ویرایش سرویس فعال باشد (در «سرویس جدید» چون هنوز id/uuid نیست، با پیام «ابتدا سرویس را ذخیره کنید» غیرفعال شود).
  • برای هر قرارداد یک ردیف با کنترل‌ها: covered (سوییچ)، coverage_percent (٪، ۰ تا ۱۰۰)، franchise_rials (PriceInput)، ceiling_rials (PriceInput).
  • مقدار اولیه‌ی هر ردیف از GET /api/v1/billing/tenant-insurances/{uuid}/service-coverage پر شود (ردیفِ متناظر با همین سرویس).
  • ذخیره‌ی هر ردیف با PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage و body:
{
  service_item_uuid: string,          // طبق وظیفه ۳ (یا service_item_id اگر id را expose کردی)
  covered: boolean,
  coverage_percent: number | null,
  franchise_rials: number | null,
  ceiling_rials: number | null,
}

از الگوهای موجود استفاده کن: api.get/put از lib/api.ts، useMutation + qc.invalidateQueries، toast، PriceInput برای مبالغ ریالی، SearchableSelect در صورت نیاز. سبک ردیف‌ها را از ServiceTariffModal.tsx الگو بگیر.

type CoverageRow = {
  contractUuid: string;
  insuranceName: string;
  type: 'basic' | 'supplementary';
  covered: boolean;
  coveragePercent: number | null;
  franchiseRials: number | null;
  ceilingRials: number | null;
};

۲. شفاف‌سازی نقش دو فیلد ساده‌ی قدیمی

insurance_covered / insurance_price_rials را حذف نکن، اما نقش‌شان را در UI روشن کن: چک‌باکس «این خدمت شامل بیمه می‌شود» به‌عنوان پرچم نمایشی/سریع بماند و بخش جدیدِ «پوشش به ازای بیمه‌گر» به‌عنوان منبعِ واقعیِ محاسبه و مطالبات معرفی شود (یک خط توضیح فارسی زیر بخش).

قبل از تصمیم، با grep بررسی کن insurance_price_rials و isInsuranceCovered() کجا مصرف می‌شوند (frontend و backend) تا چیزی نشکند.

۳. حل مشکل service_item_id در برابر uuid (backend)

frontend فقط uuid سرویس دارد ولی endpoint پوشش service_item_id می‌خواهد. گزینه‌ی کم‌ریسک را پیاده کن:

  • الف (ترجیح): setServiceCoverage (controller + TenantInsuranceService) را طوری گسترش بده که اگر service_item_id نبود ولی service_item_uuid بود، id را از ServiceItemRepository پیدا کند و اعتبارسنجی کند سرویس متعلق به همان مطب/کلینیکِ قرارداد است (همان resolveEntity).
  • خروجی GET .../service-coverage هم باید برای هر ردیف service_item_uuid بدهد (با join یا map از service_item_id) تا frontend ردیف درست را پیدا کند.

(اگر به‌جای آن id را به ServiceItem::toArray() افزودی، همه‌ی مصرف‌کننده‌های آن را چک کن — قرارداد API تغییر می‌کند.)

۴. اطمینان از جریان به مطالبات بیمه

تأیید کن (و در صورت گسست، اصلاح کن) که زنجیره کامل برقرار است:

TenantServiceCoverageCoverageRuleBillingCalculator::calculateItem() → ست‌شدن baseInsuranceRials / supplementaryRials روی InvoiceItemClaimService::buildClaim() که فقط آیتم‌های دارای سهم مثبت را به ClaimItem تبدیل می‌کند.

اگر جایی این زنجیره به‌جای TenantServiceCoverage از insurance_price_rials می‌خواند، همان نقطه را اصلاح کن تا سرویسِ بیمه‌داری که اینجا تعریف می‌شود واقعاً در مطالبات بیمه ظاهر شود. این هسته‌ی خواسته‌ی کاربر است.

۵. مستندسازی

docs/api/insurance.md را به‌روز کن: payloadِ PUT/GET .../service-coverage (به‌خصوص افزودن service_item_uuid)، و توضیح اینکه پوشش سرویس از این مسیر روی محاسبه‌ی سهم و ساخت مطالبات اثر می‌گذارد.

نکات مهم

  • معماری دوگانه را به‌هم نریز: منبعِ حقیقتِ محاسبه TenantServiceCoverage است، نه insurance_price_rials.
  • پایه vs تکمیلی: نوع بیمه‌گر از Insurance مرجع می‌آید (InsuranceType: basic|supplementary). در UI نوع را نشان بده؛ در BillingCalculator مکمل روی باقیمانده‌ی بعد از پایه اعمال می‌شود.
  • چندمستأجری (tenant): endpointهای service-coverage با resolveEntity($user) مالکیت قرارداد را چک می‌کنند؛ گسترش backend باید همان مالکیت را برای سرویس هم اعمال کند.
  • حالت ساخت اولیه: بخش پوشش فقط در حالت ویرایش (سرویس باید id داشته باشد). ساده‌ترین مسیر، یا بعد از ساختِ سرویس خودکار modal ویرایش باز شود.
  • مقادیر: مبالغ با PriceInput و واحد ریال؛ coverage_percent عدد ۰ تا ۱۰۰.
  • الگوهای پروژه: پاسخ‌ها با $this->success()/$this->error()؛ تاریخ‌ها Unix timestamp؛ admin با TanStack Query + RHF + Zod؛ JWT از localStorage['clinicpro-auth'].
  • اگر ServiceItem تغییر کرد ⇒ migration با doctrine:migrations:diff.
  • بعد از تغییر frontend: ddev exec npx tsc --noEmit و ddev exec yarn dev.
  • پس از تغییر کد: graphify update .