Multi-tenant insurance contracts, service coverage, versioned tariffs, invoice calculation, and insurance claims with debt reporting. - TenantInsurance: per-tenant insurance contracts (coverage/franchise/ceiling, versioning, soft-deactivate) + active guard - ServiceItem.insuranceCovered + TenantServiceCoverage per-service overrides - Tariff: versioned yearly tariffs with fallback to ServiceItem price - Billing domain: Money/ShareBreakdown VOs, BillingCalculator (unit-tested), Invoice/InvoiceItem aggregate, InvoiceService.createFromSession - Claim/ClaimItem with state machine (pending->submitted->approved/rejected->paid), ClaimService, insurance-debt report - ClaimSubmitterInterface + ManualClaimSubmitter (future insurance API ready) - Admin UI: insurance-pricing page, claims page, service tariff modal, service insurance toggle; routes + sidebar entries - Architecture doc + billing/insurance/clinic-services API docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 KiB
سیستم صورتحساب و بیمه (Insurance & Medical Billing) — سند معماری
وضعیت: طرح معماری (Design) — هنوز پیادهسازی نشده. بر اساس کد فعلی Clinic Pro (Symfony 7.4 / PHP 8.2+ / Doctrine / MariaDB) و الگوهای موجود نوشته شده. دامنههای جدید پیشنهادی:
App\Billing\*(Invoice/InvoiceItem/Claim/Tariff) و توسعهیApp\Insurance\*(TenantInsurance/Coverage).
۰. تطبیق با کد فعلی (مبنای واقعی، نه greenfield)
| مفهوم پرامپت | معادل واقعی در Clinic Pro | تصمیم |
|---|---|---|
| Tenant | موجودیت مستقل وجود ندارد. tenancy بهصورت polymorphic (entityType ∈ {doctor, clinic}, entityId) پیاده شده (الگوی PatientRecord, EntityInsurancePricing, SmsSettings, SubscriptionService). |
یک Value Object TenantRef(type, id) معرفی میشود؛ موجودیت Tenant جدید ساخته نمیشود. resolve از #[CurrentUser] مثل PatientController::resolveEntity. |
| InsuranceCompany | App\Insurance\Entity\Insurance (لیست master سراسری، مدیریتشده توسط admin، دارای type: InsuranceType{basic,supplementary}). |
حفظ میشود بهعنوان master سراسری. tenant آن را «فعال» میکند، تعریف نمیکند. |
| TenantInsurance | EntityInsurancePricing فعلی (polymorphic، فقط patient_share_rials) — ناکافی. |
به TenantInsurance ارتقا مییابد (قرارداد tenant↔insurance با نسخه و وضعیت). EntityInsurancePricing migrate میشود. |
| BaseInsurance / SupplementaryInsurance | InsuranceType enum (basic/supplementary). |
همان enum؛ موجودیت جدا لازم نیست (نوع روی Insurance است). |
| Service | App\ClinicService\Entity\ServiceItem (دارای priceRials, active, متعلق به ServiceSection). |
حفظ + افزودن insuranceCovered. تعرفه به Tariff منتقل میشود. |
| Tariff | وجود ندارد (قیمت مستقیم روی ServiceItem.priceRials). |
موجودیت جدید Tariff با نسخهبندی سالانه (effective year/range). |
| Encounter | App\Patient\Entity\PatientSession (مراجعه؛ دارای فیلدهای بیمه/قیمت ساده). |
PatientSession نقش Encounter را دارد؛ Invoice به آن متصل میشود. |
| Invoice / InvoiceItem | وجود ندارد (محاسبهی ساده در PatientService::calculateFinalPrice). |
موجودیتهای جدید Invoice/InvoiceItem با تفکیک سهم بیمار/پایه/مکمل. |
| Claim | وجود ندارد. | موجودیت جدید با state machine (الگوی Appointment::transitionTo). |
| Patient | PatientRecord (+ UserProfile برای دموگرافیک/بیمهی بیمار). |
حفظ. بیمهی بیمار از UserProfile.basicInsuranceId/supplementaryInsuranceId. |
| Payment | App\Payment\Entity\Payment (درگاه پرداخت). |
سهم بیمار از Invoice → میتواند به Payment وصل شود. |
اصول حفظشده: پول = int ریال؛ تاریخ = Unix timestamp (نه DateTime)؛ کنترلرها از BaseController (success/paginated/error)؛ لیست admin با DQL array hydration؛ خطاها در ErrorCodes؛ multi-tenant با (entityType, entityId).
۱. Bounded Contexts
Insurance (master + tenant contracts + coverage)
Insurance (master, global, admin) ← موجود
TenantInsurance (قرارداد tenant↔insurance) ← ارتقای EntityInsurancePricing
InsuranceCoverage (قانون پوشش هر بیمه)
TenantServiceCoverage (override پوشش یک خدمت خاص)
Billing (encounter → invoice → claim)
Tariff (تعرفهی نسخهدار سالانه)
Invoice / InvoiceItem
Claim (چرخهی مطالبات بیمه)
BillingCalculator (دامنهی محاسبه — Domain Service)
Patient (موجود) ClinicService (موجود + insuranceCovered)
Subscription (موجود — gate ویژگی billing)
۲. Domain Model — موجودیتها و فیلدها
2.1 Value Objects
// App\Shared\ValueObject\TenantRef
final readonly class TenantRef
{
public function __construct(public string $type, public int $id) {} // type: 'doctor'|'clinic'
public static function doctor(int $id): self { return new self('doctor', $id); }
public static function clinic(int $id): self { return new self('clinic', $id); }
}
// App\Billing\ValueObject\Money (ریال، صحیح، بدون منفی)
final readonly class Money
{
public function __construct(public int $rials) { if ($rials < 0) throw new \InvalidArgumentException('negative money'); }
public function add(Money $o): self { return new self($this->rials + $o->rials); }
public function sub(Money $o): self { return new self(max(0, $this->rials - $o->rials)); }
public function percent(float $p): self { return new self((int) round($this->rials * $p / 100)); }
public function min(Money $o): self { return new self(min($this->rials, $o->rials)); }
}
// App\Billing\ValueObject\ShareBreakdown (خروجی محاسبهی یک آیتم)
final readonly class ShareBreakdown
{
public function __construct(
public int $totalRials,
public int $baseInsuranceRials,
public int $supplementaryRials,
public int $patientRials,
) {}
}
// App\Insurance\ValueObject\CoverageRule
final readonly class CoverageRule
{
public function __construct(
public float $coveragePercent, // درصد پوشش این بیمه
public int $franchiseRials, // فرانشیز ثابت سهم بیمار
public ?int $ceilingRials, // سقف تعهد هر آیتم (null = بینهایت)
public bool $covered = true,
) {}
}
2.2 Entities (جداول)
TenantInsurance (ارتقای EntityInsurancePricing)
قرارداد یک tenant با یک بیمه. نسخهدار، قابل غیرفعالسازی بدون حذف.
| ستون | نوع | توضیح |
|---|---|---|
| id | int PK | |
| uuid | string(36) unique | |
| entity_type | string(10) | doctor/clinic |
| entity_id | int | |
| insurance_id | int FK→insurances | |
| version | int | نسخهی قرارداد (۱، ۲، …) |
| is_active | bool | غیرفعال بدون حذف |
| coverage_percent | decimal(5,2) | پوشش پیشفرض قرارداد |
| franchise_rials | int | فرانشیز پیشفرض |
| annual_ceiling_rials | int null | سقف تعهد سالانهی بیمار/بیمه |
| effective_from | int (unix) | شروع اعتبار قرارداد |
| effective_to | int null | پایان (null = جاری) |
| created_at / updated_at | int |
UniqueConstraint: (entity_type, entity_id, insurance_id, version). Index: (entity_type, entity_id, is_active).
TenantServiceCoverage
override پوشش یک خدمت خاص تحت یک بیمهی tenant (آیا پوشش دارد + درصد/فرانشیز/سقف اختصاصی).
| ستون | نوع |
|---|---|
| id, uuid | |
| tenant_insurance_id | int FK→tenant_insurances |
| service_item_id | int FK→service_items |
| covered | bool |
| coverage_percent | decimal(5,2) null (null=ارث از قرارداد) |
| franchise_rials | int null |
| ceiling_rials | int null |
UniqueConstraint: (tenant_insurance_id, service_item_id).
Tariff (تعرفهی نسخهدار)
تعرفهی یک خدمت برای یک سال. تاریخچه حفظ میشود.
| ستون | نوع | توضیح |
|---|---|---|
| id, uuid | ||
| service_item_id | int FK | |
| year | smallint | سال شمسی (۱۴۰۳ …) |
| price_rials | int | تعرفهی آن سال |
| effective_from / effective_to | int (unix) | بازهی اعتبار |
| is_active | bool |
UniqueConstraint: (service_item_id, year). ServiceItem.priceRials بهعنوان تعرفهی پیشفرض/جاری باقی میماند (fallback).
Invoice
صورتحساب یک Encounter (PatientSession).
| ستون | نوع |
|---|---|
| id, uuid | |
| entity_type, entity_id | tenant |
| patient_session_id | int FK→patient_sessions (Encounter) |
| patient_record_id | int FK |
| base_insurance_id | int null |
| supplementary_insurance_id | int null |
| total_rials | int |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |
| status | string(15) draft|finalized|paid|void |
| issued_at | int (unix) |
| created_at, updated_at | int |
InvoiceItem
یک خط صورتحساب (خدمت/ویزیت).
| ستون | نوع |
|---|---|
| id, uuid | |
| invoice_id | int FK |
| service_item_id | int null (ویزیت میتواند null باشد) |
| title | string |
| service_code | string null |
| tariff_rials | int (تعرفهی واحد) |
| quantity | int |
| total_rials | int (= tariff × qty) |
| base_coverage_percent | decimal(5,2) |
| supp_coverage_percent | decimal(5,2) |
| franchise_rials | int |
| ceiling_rials | int null |
| base_insurance_rials | int |
| supplementary_rials | int |
| patient_rials | int |
Claim (مطالبهی بیمه)
گروهی از Invoiceها/InvoiceItemها که به یک بیمه ارسال میشود.
| ستون | نوع |
|---|---|
| id, uuid | |
| entity_type, entity_id | tenant |
| insurance_id | int FK |
| insurance_kind | string base|supplementary |
| total_claimed_rials | int |
| total_approved_rials | int null |
| total_paid_rials | int null |
| status | string(15) pending|submitted|approved|rejected|paid |
| reject_reason | text null |
| submitted_at / settled_at | int null |
| created_at, updated_at | int |
ClaimItem
ارتباط Claim ↔ InvoiceItem (many-to-many با مبلغ ادعاشده per item).
| ستون | نوع |
|---|---|
| id | |
| claim_id | int FK |
| invoice_item_id | int FK |
| claimed_rials | int |
| approved_rials | int null |
۳. Entity Diagram (ERD متنی)
Insurance (master, global)
▲ insurance_id
│
TenantInsurance ──< TenantServiceCoverage >── ServiceItem ──< Tariff
(entity_type,id) │ service_item_id
│
PatientRecord ──< PatientSession(Encounter) ──1:1 Invoice ──< InvoiceItem
│ ▲ invoice_item_id
│ │
base/supp ins ClaimItem >── Claim ── Insurance
(entity_type,id)
UserProfile.basicInsuranceId / supplementaryInsuranceId → بیمهی پیشفرض بیمار
۴. Aggregates (DDD)
| Aggregate Root | شامل | Invariantها |
|---|---|---|
| Invoice | InvoiceItem[] | جمع سهمها = total هر آیتم؛ مجموع سطرها = total فاکتور؛ تغییر فقط در draft. |
| Claim | ClaimItem[] | فقط InvoiceItemهای finalized؛ transition وضعیت طبق state machine؛ approved_rials ≤ claimed_rials. |
| TenantInsurance | TenantServiceCoverage[] | فقط بیمههای فعالِ همان tenant؛ نسخهی جدید قبلی را غیرفعال نمیکند مگر صریح. |
Encounter (PatientSession) خارج از Aggregate صورتحساب است؛ Invoice به آن ارجاع میدهد (نه ownership).
۵. منطق محاسبهی سهم (Domain Service)
// App\Billing\Service\BillingCalculator
final class BillingCalculator
{
/**
* محاسبهی سهم برای یک آیتم.
* ترتیب: کل → پوشش پایه (با سقف) → باقیماندهی بیمار → پوشش مکمل روی باقیمانده → فرانشیز ثابت سهم بیمار.
*/
public function calculateItem(
Money $total,
?CoverageRule $base, // قانون بیمهی پایه (یا null)
?CoverageRule $supplementary, // قانون بیمهی مکمل (یا null)
): ShareBreakdown {
$baseShare = new Money(0);
$remaining = $total;
if ($base !== null && $base->covered) {
$baseShare = $total->percent($base->coveragePercent);
if ($base->ceilingRials !== null) {
$baseShare = $baseShare->min(new Money($base->ceilingRials));
}
$remaining = $total->sub($baseShare);
}
$suppShare = new Money(0);
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);
}
// فرانشیز همیشه سهم بیمار است (به remaining اضافه میشود، از سهم بیمه کم نمیکند مگر طراحی دیگر)
$franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0));
$patient = $remaining->add($franchise)->min($total); // سهم بیمار از کل بیشتر نشود
// اصلاح: اگر فرانشیز باعث شد جمع > total شود، سهم بیمهها کم نمیشود؛ این Invariant باید تست شود.
return new ShareBreakdown(
totalRials: $total->rials,
baseInsuranceRials: $baseShare->rials,
supplementaryRials: $suppShare->rials,
patientRials: $patient->rials,
);
}
}
نمونه (مطابق پرامپت): کل 600,000؛ پایه 70% → 420,000؛ مکمل روی باقیماندهی 180,000 با ≈66.7% → 120,000؛ سهم بیمار 60,000.
نکتهی طراحی: تعامل فرانشیز و سقف باید با Test پوشش داده شود؛ منطق بالا یک baseline است و قابل تنظیم per-insurance.
۶. Repository / Service / DTO
Repositoryها (الگوی ServiceEntityRepository + save/remove):
TenantInsuranceRepository (findActiveByTenant, findContractFor), TenantServiceCoverageRepository, TariffRepository (findForServiceYear), InvoiceRepository, ClaimRepository (findByStatus, debtReport).
Service Layer:
TenantInsuranceService— فعالسازی/غیرفعال/نسخهبندی قرارداد، گرفتنCoverageRuleبرای (tenant, insurance, serviceItem).BillingCalculator— محاسبهی خالص (بالا).InvoiceService— ساخت Invoice از Encounter: برای هر خدمت → resolve تعرفه (Tariff سال) + CoverageRule بیمههای بیمار →BillingCalculator→ InvoiceItem؛ finalize.ClaimService— گروهبندی InvoiceItemها بر اساس بیمه، ساخت Claim، transition وضعیت، گزارش بدهی.
DTOها: CreateInvoiceRequest, InvoiceItemDTO, TenantInsuranceDTO, CoverageRuleDTO, ClaimDTO, BillingPreviewResponse (پیشنمایش زندهی محاسبه قبل از ثبت).
۷. REST API (تحت /api/v1, BaseController, tenant از #[CurrentUser])
TenantInsurance (قراردادهای بیمهی tenant)
GET /api/v1/billing/tenant-insurances— لیست قراردادهای فعال tenantPOST /api/v1/billing/tenant-insurances— فعالسازی بیمه برای tenant (با coverage_percent/franchise/ceiling)PATCH /api/v1/billing/tenant-insurances/{uuid}— ویرایش (نسخهی جدید)DELETE /api/v1/billing/tenant-insurances/{uuid}— غیرفعالسازی (soft)PUT /api/v1/billing/tenant-insurances/{uuid}/service-coverage— تنظیم پوشش خدمات
Tariff
GET /api/v1/billing/services/{serviceUuid}/tariffsPUT /api/v1/billing/services/{serviceUuid}/tariffs/{year}— ثبت/ویرایش تعرفهی سال
Invoice
POST /api/v1/billing/invoices/preview— پیشنمایش محاسبه (بدون ذخیره)POST /api/v1/billing/invoices— ساخت از EncounterGET /api/v1/billing/invoices/{uuid}POST /api/v1/billing/invoices/{uuid}/finalize
Claim
POST /api/v1/billing/claims— ساخت از InvoiceItemهای finalized یک بیمهPOST /api/v1/billing/claims/{uuid}/submit|approve|reject|payGET /api/v1/billing/claims?status=...GET /api/v1/billing/reports/insurance-debt— گزارش بدهی بیمهها
Guard مهم (نیازمندی ۱۴): هنگام پذیرش بیمار و ساخت Invoice، فقط بیمههایی مجازند که TenantInsurance.is_active = true برای همان tenant. در غیر این صورت ERR_VALIDATION («این بیمه برای این کلینیک فعال نیست»).
۸. Workflow
ثبت صورتحساب
Encounter(PatientSession) ثبت میشود
→ بیمهی بیمار از UserProfile یا انتخاب منشی (محدود به TenantInsurance فعال)
→ InvoiceService.createFromEncounter:
برای هر خدمت:
tariff = TariffRepository.findForServiceYear(service, سالِ جاری) ?? service.priceRials
coverage_base = TenantInsuranceService.coverageRule(tenant, baseIns, service)
coverage_supp = TenantInsuranceService.coverageRule(tenant, suppIns, service)
breakdown = BillingCalculator.calculateItem(...)
→ InvoiceItem
→ Invoice (status=draft) → finalize (status=finalized)
Claim
InvoiceItemهای finalized یک بیمه → ClaimService.create (status=pending)
→ submit (submitted) → [پاسخ بیمه] approve/reject → pay (paid)
گزارش بدهی = جمع claimed - paid بر اساس بیمه
state machine Claim (الگوی Appointment::canTransitionTo):
pending → submitted → {approved → paid | rejected}؛ از paid/rejected خروج ندارد.
۹. Events / Use Cases
Domain Events: InvoiceFinalized, ClaimSubmitted, ClaimApproved, ClaimRejected, ClaimPaid, TenantInsuranceDeactivated.
کاربرد: SMS/notification به بیمار، بهروزرسانی گزارش بدهی، آینده: ارسال خودکار به API بیمه.
Use Cases اصلی: فعالسازی بیمه برای tenant؛ تنظیم پوشش خدمت؛ ثبت تعرفهی سالانه؛ پیشنمایش محاسبهی سهم؛ ثبت Invoice از Encounter؛ ساخت/پیگیری Claim؛ گزارش بدهی.
۱۰. آینده: اتصال به API بیمه (نیازمندی ۱۰)
ClaimSubmitter بهصورت interface طراحی شود؛ پیادهسازی فعلی manual، آینده IranInsuranceApiSubmitter بدون تغییر در دامنه (Dependency Inversion).
۱۱. فازبندی پیادهسازی (پیشنهادی)
- فاز ۱ — قرارداد بیمه: ✅ انجام شد —
TenantInsurance(entity/repo/service/API/UI) + جدولtenant_insurances+ guardassertActive()+CoverageRuleVO.EntityInsurancePricingفعلاً برای ویزیت آزاد/سهم ساده باقی ماند (فاز ۴ ادغام). API:/api/v1/billing/tenant-insurances(GET/POST/PATCH/DELETE). UI: صفحهی «بیمه و قیمتگذاری». - فاز ۲ — پوشش خدمت: ✅ انجام شد —
ServiceItem.insuranceCovered+insurancePriceRials+ موجودیتTenantServiceCoverage(override per-service per-contract) +coverageRuleForService()(resolve با ارث از قرارداد) + API…/service-coverage(GET/PUT) + UI toggle «شامل بیمه» در صفحهی خدمات. - فاز ۳ — تعرفه: ✅ انجام شد — موجودیت
Tariff(service_item × سال شمسی) +TariffService::resolvePrice()با fallback بهServiceItem.priceRials+currentJalaliYear()(IntlDateFormatter، رقم لاتین) + API…/tariffs(GET) و…/tariffs/{year}(PUT) + UI «تعرفههای سالانه» (modal از ردیف خدمت). - فاز ۴ — محاسبه + Invoice: ✅ انجام شد — VO
Money/ShareBreakdown+BillingCalculator(۶ تست واحد سبز، نمونه ۶۰۰k) +Invoice/InvoiceItem(aggregate، جداولinvoices/invoice_items) +InvoiceService::createFromSession(resolve تعرفهی Tariff + CoverageRule per-service + محاسبه) + API/api/v1/billing/invoices(POST/GET/finalize).PatientService::calculateFinalPriceفعلاً برای سازگاری باقی ماند (مصرف UI موجود). - فاز ۵ — Claim + گزارش: ✅ انجام شد —
Claim/ClaimItem(جداولclaims/claim_items) + state machine (pending→submitted→approved/rejected→paid) +ClaimService::createFromInvoice(تفکیک پایه/مکمل از InvoiceItemها) + transitions + API/api/v1/billing/claims(POST/GET +{uuid}/{action}) + گزارش…/reports/insurance-debt. - فاز ۶ — Frontend + API بیمه: ✅ انجام شد — صفحهی «مطالبات بیمه» (
ClaimsPage: لیست + فیلتر وضعیت + transitions submit/approve/reject/pay + گزارش بدهی) + route/admin/claims+ آیتم sidebar (doctor/clinic) + interfaceClaimSubmitterInterfaceبا پیادهسازی پیشفرضManualClaimSubmitter(autowired؛ آمادهی جایگزینی با API بیمهی ایران بدون تغییرClaimService). UI صورتحساب در فرم مراجعه به فاز بعدی موکول شد (نیاز به بازطراحی مودال مراجعه — پرامپتnew-visit-modal-ux.md).
توجه:
EntityInsurancePricingفعلی (همین session ساخته شد) در فاز ۱ بهTenantInsuranceتبدیل میشود؛ تا آن زمان بهعنوان نسخهی سادهی موقت کار میکند.