feat(insurance): bill an appointment with a chosen service kind and insurance

An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-25 17:50:14 +03:30
co-authored by Claude Opus 5
parent 58c6d9ac18
commit 1f58b1b9b3
47 changed files with 2693 additions and 86 deletions
+35 -3
View File
@@ -351,12 +351,25 @@ Get appointment detail.
"note": "...",
"patient_name": "...",
"patient_mobile": "...",
"insurance_service_category": "inpatient",
"insurance_service_category_label": "خدمات بستری",
"insurance_base_id": 3,
"created_at": 1717000000
}
}
```
> `doctor.specialties` آرایه (ممکن است خالی)؛ `address` اولین آدرس پزشک است (ممکن است `null` اگر پزشک آدرسی ندارد). `address.map.latitude/longitude` رشته یا `null`. تاریخ‌ها Unix.
### انتخاب بیمهٔ نوبت
| فیلد | نوع | توضیح |
|------|-----|-------|
| `insurance_service_category` | string \| null | نوع خدمتِ بیمه‌ایِ این نوبت — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). `null` = انتخاب نشده؛ محاسبه به نوع پیش‌فرضِ tenant برمی‌گردد (`default_service_category` در [insurance.md](insurance.md)) |
| `insurance_service_category_label` | string \| null | برچسب فارسی همان نوع |
| `insurance_base_id` | int \| null | بیمهٔ **پایهٔ** انتخاب‌شده؛ باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد |
نام بیمه در این پاسخ نیست؛ پنل آن را از `GET /api/v1/billing/tenant-insurances` (که کش می‌شود) مپ می‌کند تا لیست‌های نوبت به N+1 نیفتند.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
@@ -530,6 +543,8 @@ transaction**. If any step fails nothing is committed.
```json
{
"version": 3,
"insurance_service_category": "inpatient",
"insurance_base_id": 3,
"payments": [
{ "method": "pos", "amount_rials": 3000000, "payment_method_uuid": "…pos-uuid…", "reference": "TRX-42" },
{ "method": "cash", "amount_rials": 2000000 }
@@ -540,6 +555,8 @@ transaction**. If any step fails nothing is committed.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `insurance_service_category` | string | ❌ | نوع خدمتِ بیمه‌ای، همان قواعد و خطاهای `PATCH /api/v1/appointment/{uuid}`. قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با همان نوع محاسبه شوند. |
| `insurance_base_id` | integer | ❌ | بیمهٔ پایه، همان قواعد و خطاهای `PATCH`. |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Several rows allowed (split payment). |
| `payments[].method` | string | ✅ | ∈ `wallet\|pos\|cash\|card` |
| `payments[].amount_rials` | integer | ✅ | > 0 |
@@ -554,12 +571,24 @@ Partial payment is normal: the remainder stays as `remaining_rials` on the visit
collected later through `POST /api/v1/session/{uuid}/payments`.
### What happens on the server
1. `pending → confirmed` (state machine still applies).
2. `AppointmentConfirmationService` files the case file for the appointment's environment
1. انتخاب بیمه (اگر در بدنه آمده باشد) روی نوبت می‌نشیند و اعتبارسنجی می‌شود.
2. `pending → confirmed` (state machine still applies).
3. `AppointmentConfirmationService` files the case file for the appointment's environment
(`appointment.clinic` → clinic, otherwise the doctor's personal office): an **existing**
record for that patient in that environment is reused, otherwise a new one is created.
A `PatientSession` is opened with the visit price and one line per attached service.
3. Each payment row is registered on that visit (`wallet` also debits the patient wallet).
4. **تفکیک بیمه** روی همان مراجعه محاسبه می‌شود (`BillingCalculator`): درصد پوشش از
نوع خدمتِ نوبت — یا تنها نوع فعالِ tenant، وگرنه سرپایی — و زنجیرهٔ resolve
([insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)). نوبتِ بدون بیمه
مثل قبل کاملاً سهم بیمار می‌ماند.
5. Each payment row is registered on that visit (`wallet` also debits the patient wallet).
> پیش از این، پرونده‌ای که با قطعی‌کردن ساخته می‌شد همیشه کل مبلغ را سهم بیمار می‌گذاشت
> (`applyShares($gross, 0, 0, $gross)`) و صفحهٔ پرداخت با فاکتور واگرا می‌شد.
پاسخ، `session` را با تفکیک بیمه برمی‌گرداند: `gross_total_rials`، `base_insurance_rials`،
`supplementary_insurance_rials`، `patient_share_rials`، `insurance_service_category`،
`insurance_base_id` — تا مودال همان مبلغی را نشان دهد که ثبت شده است.
### Response `200`
```json
@@ -834,12 +863,15 @@ General update (ویرایش / جا به جایی / انتقال به رزرو /
"service_section_uuid": "…", "service_item_uuid": "…", "staff_uuid": "…",
"deposit_required": true, "deposit_amount_rials": 5000000,
"note": "…", "patient_name": "…", "patient_mobile": "…",
"insurance_service_category": "inpatient", "insurance_base_id": 3,
"status": "confirmed", "version": 3
}
```
- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.
- `insurance_service_category` — نوعِ خدمت باید در تنظیمات بیمهٔ همان tenant **فعال** باشد؛ `null`/`""` انتخاب را پاک می‌کند. نوع نامعتبر یا غیرفعال → `422 ERR_VALIDATION_001` با فیلد `insurance_service_category`.
- `insurance_base_id` — بیمه باید قرارداد فعال روی همان پزشک/کلینیک داشته باشد و **پایه** باشد؛ `null`/`0` انتخاب را پاک می‌کند. بیمهٔ بدون قرارداد فعال یا بیمهٔ تکمیلی → `422 ERR_VALIDATION_001` با فیلد `insurance_base_id`.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.
- Optimistic lock via `version``409` on concurrent edit.
+4 -1
View File
@@ -10,7 +10,8 @@
- تعرفه‌ی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
- قانون پوشش از قرارداد بیمه‌ی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
- درصد پوشش به تفکیک **نوع خدمت** (`ServiceItem.service_category`؛ ویزیت همیشه `outpatient`) و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود.
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ resolve توضیح‌داده‌شده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته می‌شود: هر خدمت با `ServiceItem.service_category` خودش، و **ویزیت** با `PatientSession.insurance_service_category` (نوعی که سرِ پذیرش انتخاب شده؛ در نبودش سرپایی).
- `invoices.service_category` همان نوع را snapshot می‌کند و در `toArray()` به‌صورت `service_category` / `service_category_label` برمی‌گردد.
- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار.
**بیمهٔ پایه صرفاً درصدی است:**
@@ -85,6 +86,8 @@
**Response 200:** همان ساختار بالا + کلید `session` — خروجی کامل `PatientSession.toArray()` مراجعه‌ای که صورتحساب از آن ساخته شده (برای نمایش «خلاصه فاکتور»: پرداختی‌ها، کالای مصرفی، تخفیف، مبالغ پرداخت‌شده). اگر صورتحساب از session ساخته نشده باشد `session: null`.
همچنین برای درج روی فاکتور: `service_category` / `service_category_label` (نوع خدمتِ بیمه‌ای) و `base_insurance_name` / `supplementary_insurance_name` (نام بیمه‌ها؛ فاکتور خودش فقط شناسه را نگه می‌دارد).
```json
{
"success": true,
+20
View File
@@ -431,6 +431,23 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
}
```
همچنین دو کلید سراسریِ tenant:
```json
{
"service_categories": [
{ "key": "outpatient", "label": "خدمات سرپایی", "enabled": true },
{ "key": "inpatient", "label": "خدمات بستری", "enabled": false }
],
"default_service_category": "outpatient"
}
```
| فیلد | توضیح |
|------|-------|
| `service_categories` | نوع خدماتی که بیمه‌های این پزشک/کلینیک پوشش می‌دهند — **سراسری برای همهٔ بیمه‌های همان tenant**، نه per-insurance. همیشه همهٔ نوع‌ها برمی‌گردند؛ نبودِ ردیف در DB = `enabled: true`. پنل: کارت «نوع خدمات بیمه» در تنظیمات ← مدیریت بیمه (`/admin/insurance-pricing`) |
| `default_service_category` | اگر فقط یک نوع فعال باشد همان (مبنای خودکار محاسبه)؛ اگر بیش از یکی فعال باشد `null` و پنل باید سرِ پذیرش بپرسد |
- `coverage_defaults` — درصدهای مرکزی ادمین؛ پنل پزشک هنگام افزودن قرارداد از همین پر می‌کند.
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد). این مقدار **ورودی هیچ محاسبه‌ای نیست**؛ محاسبهٔ سهم فقط از درصد پوشش انجام می‌شود.
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند.
@@ -467,8 +484,10 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
| `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیره‌شده حفظ می‌شود. |
| `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). |
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. |
| `service_categories` | array | اختیاری — `[{ "key": "inpatient", "enabled": false }]`. فقط نوع‌های ارسالی تغییر می‌کنند؛ نیامدنِ کلید یعنی تنظیمات دست‌نخورده. |
اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیره‌شده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیره‌شده) `<= 0`، درخواست رد می‌شود.
اعتبارسنجی `service_categories`: `key ∈ ServiceCategory::values()` و پس از اعمالِ تغییر **حداقل یک نوع فعال بماند** — وگرنه `422 ERR_VALIDATION_001` با فیلد `service_categories`.
### Response `200`
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
@@ -476,6 +495,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`.
- `422` `ERR_VALIDATION_001` (field: `service_categories`) — نوع خدمت نامعتبر، یا غیرفعال‌کردن همهٔ نوع‌ها.
---
+6
View File
@@ -504,6 +504,8 @@ Creates a new visit session for a patient record.
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع).
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `price_rials` snapshot از `InventoryItem.price`؛ `quantity` (پیش‌فرض ۱، حداقل ۱). کالاها **پوشش بیمه ندارند** و مبلغ کاملشان به `final_price_rials` (سهم بیمار) اضافه می‌شود. آیتم ناموجود یا متعلق به tenant دیگر بی‌صدا رد می‌شود (هم‌رفتار با `services`). پاسخ شامل `consumables[]` (با `line_total_rials`) و `consumables_total_rials` است.
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیش‌فرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
- پاسخ‌های مراجعه (لیست، ایجاد، ویرایش) علاوه بر شناسه‌ها، `insurance_base_name` و `insurance_supplementary_name` را هم دارند (نام‌ها با کش درون‌درخواستی حل می‌شوند، بدون N+1) تا کارت «سرویس‌ها» در پرونده بیمار بتواند نوع خدمت و بیمه را نشان دهد.
- `insurance_service_category` (اختیاری): نوع خدمتِ بیمه‌ایِ این مراجعه — یکی از مقادیر [`GET /api/v1/service-categories`](clinic-services.md#get-apiv1service-categories). درصد پوششِ **ویزیت** از همین نوع resolve می‌شود؛ سهم هر خدمت همچنان از `service_category` خودِ خدمت می‌آید. `null` → سرپایی. در پاسخ به‌همراه `insurance_service_category_label` برمی‌گردد و در `PATCH /api/v1/session/{uuid}` هم پذیرفته می‌شود.
- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد مؤثر قرارداد فعال (زنجیرهٔ resolve — [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)، با نوع خدمتِ `outpatient` برای ویزیت) بازنویسی می‌شود؛ صرفاً snapshot برای نمایش/گزارش‌اند.
- `final_price_rials` (سهم بیمار) به این صورت محاسبه می‌شود:
- **ویزیت:** خدمتِ سرپایی است و با قاعده‌ی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` حساب می‌شود — همان مسیری که `InvoiceService` برای صدور فاکتور می‌رود. سهم بیمهٔ پایه = `round(کل × درصد ÷ 100)` و سهم بیمار = `کل سهم پایه`؛ فرانشیزِ قرارداد پایه بی‌اثر است.
@@ -687,6 +689,10 @@ GET /api/v1/session/{uuid}/audit-log
1. اگر بیمار در آن محیط پرونده نداشته باشد، یک `PatientRecord` می‌سازد
2. یک `PatientSession` گره‌خورده به همان نوبت می‌سازد (زمان مراجعه = زمان نوبت، هزینه ویزیت
و خطوط سرویس از خود نوبت snapshot می‌شوند)
3. **بیمهٔ نوبت** (`appointments.insurance_base_id` + `insurance_service_category`) روی مراجعه
snapshot می‌شود و سهم بیمه/بیمار با `BillingCalculator` محاسبه می‌گردد. نوع خدمتِ مؤثر:
انتخابِ نوبت → تنها نوع فعالِ tenant → سرپایی. نوبتِ بدون بیمه کاملاً سهم بیمار می‌ماند
(رفتار قبلی).
### محیط پرونده — یکی، نه هر دو (2026-07)