- 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.
14 KiB
اتصال «بیمهی سرویس» در صفحه سرویسهای کلینیک به سامانهی واقعی پوشش بیمه و مطالبات
پروژه
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 تغییر میکند.)
۴. اطمینان از جریان به مطالبات بیمه
تأیید کن (و در صورت گسست، اصلاح کن) که زنجیره کامل برقرار است:
TenantServiceCoverage → CoverageRule → BillingCalculator::calculateItem() → ستشدن baseInsuranceRials / supplementaryRials روی InvoiceItem → ClaimService::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 .