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>
441 lines
26 KiB
Markdown
441 lines
26 KiB
Markdown
# Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
|
||
|
||
> **Prefix:** `/api/v1/billing`
|
||
> دامنه: `App\Billing`. مرجع معماری: `docs/architecture/insurance-billing-system.md`.
|
||
> tenant از `#[CurrentUser]` با **`App\Patient\Security\PatientRecordScopeResolver`** resolve میشود — همان رزولوری که پروندههای بیمار استفاده میکنند، چون صورتحساب و مطالبه از دل مراجعه بیرون میآیند و باید در همان محیط دیده شوند. محیط فعال (`UserActiveContext`) تعیینکننده است، نه صرفاً ترتیب نقشها. منشیِ فعال هم میتواند صورتحسابهای همان tenant را ببیند/بسازد. جزئیات جدول محیطها: [patient.md](patient.md#record-access-model).
|
||
>
|
||
> پیش از این، این دامنه ترتیب نقشها را خودش پیاده کرده بود و اول `ROLE_DOCTOR` را میگرفت؛ در نتیجه **مالک کلینیکی که خودش پزشک هم هست** به مطب شخصیاش نگاشت میشد و صورتحساب/مطالبهی کلینیک خودش را `404` میگرفت. همین رزولور در `InsuranceController` هم استفاده میشود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.
|
||
|
||
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته میشود. برای هر آیتم سهم بیمهی پایه، بیمهی مکمل و بیمار با `BillingCalculator` محاسبه میشود:
|
||
|
||
- تعرفهی خدمت از `Tariff` سال جاری (با fallback به `ServiceItem.priceRials`).
|
||
- قانون پوشش از قرارداد بیمهی tenant (`TenantInsurance`) + override خدمت (`TenantServiceCoverage`).
|
||
- درصد پوشش به تفکیک **نوع خدمت** و از زنجیرهٔ 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` برمیگردد.
|
||
- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار.
|
||
|
||
**بیمهٔ پایه صرفاً درصدی است:**
|
||
|
||
```
|
||
سهم بیمهٔ پایه = round(کل × درصد پوشش پایه ÷ 100)
|
||
سهم بیمار = کل − سهم بیمهٔ پایه
|
||
```
|
||
|
||
`franchise_rials` قرارداد پایه در محاسبه **بیاثر** است (ستون برای سازگاری و قراردادهای تکمیلی میماند).
|
||
|
||
نمونهها:
|
||
- کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
|
||
- ویزیت ۵٬۹۵۲٬۰۰۰ ریال · پایهٔ بستری ۳۰٪ → سهم پایه ۱٬۷۸۵٬۶۰۰ · سهم بیمار ۴٬۱۶۶٬۴۰۰.
|
||
|
||
---
|
||
|
||
## POST /api/v1/billing/invoices
|
||
|
||
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده میشود (idempotent).
|
||
|
||
**Permission:** `AUTH` (مالک مراجعه)
|
||
|
||
**Body:**
|
||
```json
|
||
{ "session_uuid": "…" }
|
||
```
|
||
|
||
**Response 201:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"uuid": "…",
|
||
"entity_type": "doctor",
|
||
"entity_id": 7,
|
||
"patient_session_id": 33,
|
||
"base_insurance_id": 3,
|
||
"supplementary_insurance_id": 9,
|
||
"total_rials": 600000,
|
||
"base_insurance_rials": 420000,
|
||
"supplementary_rials": 120000,
|
||
"patient_rials": 60000,
|
||
"status": "draft",
|
||
"issued_at": 1718900000,
|
||
"items": [
|
||
{
|
||
"uuid": "…",
|
||
"service_item_id": 12,
|
||
"title": "ویزیت",
|
||
"tariff_rials": 600000,
|
||
"quantity": 1,
|
||
"total_rials": 600000,
|
||
"base_insurance_rials": 420000,
|
||
"supplementary_rials": 120000,
|
||
"patient_rials": 60000
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Errors:** `422 ERR_VALIDATION_001` session_uuid الزامی · `404 ERR_NOT_FOUND_001` مراجعه یافت نشد · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
|
||
|
||
---
|
||
|
||
## GET /api/v1/billing/invoices/{uuid}
|
||
|
||
دریافت صورتحساب (فقط مالک tenant)، غنیشده با مراجعهی مبدأ.
|
||
|
||
**Response 200:** همان ساختار بالا + کلید `session` — خروجی کامل `PatientSession.toArray()` مراجعهای که صورتحساب از آن ساخته شده (برای نمایش «خلاصه فاکتور»: پرداختیها، کالای مصرفی، تخفیف، مبالغ پرداختشده). اگر صورتحساب از session ساخته نشده باشد `session: null`.
|
||
|
||
همچنین برای درج روی فاکتور: `service_category` / `service_category_label` (نوع خدمتِ بیمهای) و `base_insurance_name` / `supplementary_insurance_name` (نام بیمهها؛ فاکتور خودش فقط شناسه را نگه میدارد).
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"uuid": "…",
|
||
"total_rials": 600000,
|
||
"status": "finalized",
|
||
"items": [ … ],
|
||
"session": {
|
||
"uuid": "…",
|
||
"session_at": 1718900000,
|
||
"paid_at": 1718990000,
|
||
"services_total_rials": 2400000,
|
||
"consumables_total_rials": 40000,
|
||
"discount_rials": 200000,
|
||
"final_price_rials": 2240000,
|
||
"paid_total_rials": 1500000,
|
||
"payments": [
|
||
{ "uuid": "…", "method": "wallet", "amount_rials": 1500000, "paid_at": 1718990000, "created_by_name": "منشی تست", "created_at": 1718990000 }
|
||
],
|
||
"consumables": [
|
||
{ "uuid": "…", "item_name": "عینک", "quantity": 2, "price_rials": 20000, "line_total_rials": 40000 }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Errors:** `404 ERR_NOT_FOUND_001`.
|
||
|
||
---
|
||
|
||
## POST /api/v1/billing/invoices/{uuid}/finalize
|
||
|
||
نهاییسازی صورتحساب (`draft` → `finalized`). صورتحساب نهاییشده مبنای ساخت Claim (فاز ۵) است.
|
||
|
||
**Response 200:** صورتحساب با `status: "finalized"`.
|
||
**Errors:** `404 ERR_NOT_FOUND_001`.
|
||
|
||
---
|
||
|
||
## وضعیتهای Invoice
|
||
`draft` (پیشنویس، قابل بازسازی) → `finalized` (نهایی) → `paid` (پرداختشده) · `void` (باطل).
|
||
|
||
## نکات
|
||
- پول: integer ریال. تاریخ: Unix timestamp.
|
||
- `BillingCalculator` خالص و واحد-تستشده است (`tests/Billing/BillingCalculatorTest.php`).
|
||
- این فاز جایگزین تدریجی `PatientService::calculateFinalPrice` است؛ آن متد فعلاً برای سازگاری باقی مانده.
|
||
|
||
---
|
||
|
||
# Claims — مطالبات بیمه (فاز ۵)
|
||
|
||
مطالبه (`Claim`) از یک صورتحساب **نهاییشده** ساخته میشود: یک Claim برای بیمهی پایه و یک Claim برای بیمهی مکمل (فقط اگر سهم بیمه > ۰). هر `ClaimItem` به یک `InvoiceItem` ارجاع میدهد. چرخهی وضعیت با state machine.
|
||
|
||
**ساخت خودکار:** هنگام ثبت session بیمهدار (`POST /api/v1/patient/{uuid}/session`)، زنجیرهی صورتحساب→نهاییسازی→مطالبه بهصورت خودکار اجرا میشود؛ نیازی به فراخوانی دستی `POST /billing/claims` نیست. برای هر صورتحساب تنها یکبار مطالبه ساخته میشود (تلاش مجدد `422`).
|
||
|
||
**وضعیتها:** `pending → submitted → {approved → paid | rejected}`. از `paid`/`rejected` خروجی ندارد.
|
||
|
||
## POST /api/v1/billing/claims
|
||
ساخت مطالبات از یک صورتحساب نهاییشده.
|
||
|
||
**Body:** `{ "invoice_uuid": "…" }`
|
||
**Response 201:** `{ success, data: [ …claims ] }` (یک یا دو مطالبه: پایه/مکمل).
|
||
**Errors:** `422` صورتحساب نهایی نشده / سهم بیمه ندارد / مطالبه از قبل ثبت شده · `404` صورتحساب یافت نشد.
|
||
|
||
هر مطالبه در پاسخ علاوه بر `insurance_id`، فیلد `insurance_name` (نام بیمه، یا `null` اگر بیمه حذف شده باشد) را نیز دارد. در `POST` و `transition` و `GET` یکسان است.
|
||
|
||
## GET /api/v1/billing/claims
|
||
لیست مطالبات tenant با فیلترهای اختیاری (query params):
|
||
|
||
| پارامتر | نوع | توضیح |
|
||
|---------|-----|-------|
|
||
| `status` | string | `pending\|submitted\|approved\|rejected\|paid` |
|
||
| `insurance_id` | int | فقط مطالبات یک بیمهگر |
|
||
| `from` | int (Unix) | مطالبات با `created_at >= from` (شروع بازه) |
|
||
| `to` | int (Unix) | مطالبات با `created_at <= to` (پایان بازه) |
|
||
| `q` | string | جستجوی بیمار: موبایل، کدملی یا نام (LIKE) |
|
||
| `page` | int | شماره صفحه (پیشفرض ۱) |
|
||
| `limit` | int | تعداد در هر صفحه (پیشفرض ۵۰، حداکثر ۱۰۰) |
|
||
|
||
> **صفحهبندی:** پاسخ علاوه بر `data.data` (آرایهی مطالبات) یک `data.meta` با `totalRecords`/`totalPages`/`currentPage` دارد. پاکت قبلی (`data.data`) دستنخورده است؛ کلاینتهای موجود بدون تغییر کار میکنند.
|
||
|
||
> بازهی تاریخ شمسی (سال/ماه/روز خاص) در سمت کلاینت به `from`/`to` یونیکس تبدیل میشود؛ backend فقط بازهی یونیکس میگیرد. فیلتر `q` از طریق زنجیره `claim → claim_item → invoice_item → invoice → patient_record → user` با `EXISTS` اعمال میشود.
|
||
|
||
هر مطالبه شامل `insurance_name`، `patient_name`، `patient_mobile` و آرایهی `items` با جزئیات هر ردیف است.
|
||
|
||
هر عضو `items` علاوه بر `invoice_item_id`، `claimed_rials`، `approved_rials`، این فیلدهای نمایشی را دارد (برای جدول صفحهی مطالبات):
|
||
|
||
| فیلد | نوع | توضیح |
|
||
|------|-----|-------|
|
||
| `title` | string \| null | شرح ردیف؛ «ویزیت» یا نام خدمت |
|
||
| `is_visit` | bool | `true` اگر ردیف ویزیت باشد (`service_item_id` تهی) |
|
||
| `quantity` | int | تعداد |
|
||
| `total_rials` | int \| null | مبلغ کل آن ردیف (قیمت کامل، قبل از بیمه) |
|
||
| `visit_date` | int \| null | تاریخ ثبت مراجعه (Unix؛ نمایش شمسی) |
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": { "data": [
|
||
{
|
||
"uuid": "…", "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "insurance_kind": "base",
|
||
"total_claimed_rials": 240000, "status": "pending",
|
||
"items": [
|
||
{ "invoice_item_id": 12, "title": "ویزیت", "is_visit": true, "quantity": 1, "total_rials": 300000, "claimed_rials": 240000, "approved_rials": null, "visit_date": 1718000000 },
|
||
{ "invoice_item_id": 13, "title": "سرم ۵۰۰cc", "is_visit": false, "quantity": 2, "total_rials": 170000, "claimed_rials": 0, "approved_rials": null, "visit_date": 1718000000 }
|
||
]
|
||
}
|
||
] }
|
||
}
|
||
```
|
||
|
||
> این فیلدها با یک کوئری گروهی (`InvoiceItemRepository::detailsForIds` + `PatientSessionRepository::datesForIds`) پر میشوند تا N+1 رخ ندهد. همان enrichment روی پاسخ `POST /claims` و `POST /claims/{uuid}/{action}` هم اعمال میشود.
|
||
|
||
## GET /api/v1/billing/claims/by-patient
|
||
نمای سطحاول داشبورد مطالبات: **یک ردیف بهازای هر بیمار** (نه هر مطالبه)، با جمعهای تجمیعی.
|
||
|
||
**Query params:**
|
||
|
||
| Param | Type | Default | توضیح |
|
||
|-------|------|---------|-------|
|
||
| `page` | int | 1 | شماره صفحه |
|
||
| `limit` | int | 20 | حداکثر ۱۰۰ |
|
||
| `sort` | string | `last_activity_at` | `full_name` \| `claims_count` \| `total_services_rials` \| `total_insurance_rials` \| `last_activity_at` |
|
||
| `dir` | string | `desc` | `asc` \| `desc` |
|
||
| `search` | string | — | نام، موبایل یا کد ملی بیمار |
|
||
| `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` |
|
||
| `insurance_id` | int | — | شناسه بیمه |
|
||
| `doctor_id` | int | — | پزشکِ نوبتِ مراجعه |
|
||
| `payment_status` | string | — | `paid` (وصولشده) \| `unpaid` |
|
||
| `from` / `to` | int | — | بازهی `claims.created_at` (unix ثانیه) |
|
||
|
||
پاسخ paginated استاندارد (`{ success, data: [], meta }`):
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"patient_uuid": "45064492-...",
|
||
"record_uuid": "ad0a3d0e-...",
|
||
"full_name": "تست جراحی بینی",
|
||
"mobile": "09370671756",
|
||
"national_code": null,
|
||
"claims_count": 2,
|
||
"total_services_rials": 81500000,
|
||
"total_insurance_rials": 57050000,
|
||
"total_patient_rials": 24450000,
|
||
"total_approved_rials": 28000000,
|
||
"total_paid_rials": 28000000,
|
||
"overall_status": "mixed",
|
||
"last_activity_at": 1784404115
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
|
||
- `overall_status`: اگر همهی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنه `mixed`.
|
||
- ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`.
|
||
- **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحسابهای یکتا** جمع میشوند، نه از مطالبات. یک صورتحساب میتواند همزمان مطالبهی پایه و مکمل داشته باشد؛ جمعزدن از سمت مطالبه مبلغ خدمات را دوبار میشمرد.
|
||
- مطالبه لینک مستقیم به بیمار ندارد؛ زنجیرهی `claim → claim_item → invoice_item → invoice → patient_record` است.
|
||
|
||
## GET /api/v1/billing/claims/by-patient/{patientUuid}
|
||
جزئیات کامل مطالبات یک بیمار (`patientUuid` = **uuid پرونده**، همان `record_uuid` نمای سطحاول). فیلترها همان فیلترهای بالا.
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"patient": { "uuid": "...", "record_uuid": "...", "full_name": "...", "mobile": "...", "national_code": null },
|
||
"claims": [
|
||
{
|
||
"uuid": "b244b506-...",
|
||
"invoice_uuid": "15498c14-...",
|
||
"visit_date": 1784440200,
|
||
"doctor_name": "تست",
|
||
"insurance_id": 176,
|
||
"insurance_name": "تامین اجتماعی",
|
||
"insurance_kind": "base",
|
||
"service_base_rials": 41000000,
|
||
"coverage_percent": 70,
|
||
"insurance_share_rials": 28700000,
|
||
"patient_share_rials": 12300000,
|
||
"total_approved_rials": 28000000,
|
||
"total_paid_rials": 28000000,
|
||
"status": "paid",
|
||
"tracking_number": "TM-4419-88",
|
||
"reject_reason": null,
|
||
"submitted_at": 1784404200,
|
||
"settled_at": 1784404300,
|
||
"created_at": 1784404115,
|
||
"allowed_transitions": [],
|
||
"logs": [
|
||
{ "uuid": "...", "from_status": null, "to_status": "pending", "note": "ایجاد مطالبه", "by": null, "at": 1784404115 },
|
||
{ "uuid": "...", "from_status": "pending", "to_status": "submitted", "note": null, "by": "علی بهروزی", "at": 1784404200 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- `coverage_percent` محاسبهشده است: `insurance_share_rials / service_base_rials × 100`.
|
||
- `allowed_transitions` از `Claim::TRANSITIONS` میآید؛ پنل دکمهها را از همین میسازد و فهرست مجاز را hardcode نمیکند.
|
||
- `logs` تاریخچهی کامل تغییر وضعیت از جدول `claim_status_logs` است (بهترتیب زمانی صعودی).
|
||
|
||
**Errors:** `404` پرونده بیمار یافت نشد یا متعلق به tenant دیگری است · `403` پروفایل یافت نشد.
|
||
|
||
## POST /api/v1/billing/claims/{uuid}/{action}
|
||
انتقال وضعیت. `action` ∈ `submit|approve|reject|pay`. هر انتقال یک ردیف در `claim_status_logs` ثبت میکند (وضعیت مبدأ/مقصد، توضیح، کاربر، زمان).
|
||
|
||
| action | body | اثر |
|
||
|--------|------|-----|
|
||
| submit | `tracking_number` (اختیاری) | pending → submitted؛ شماره پرونده/پیگیری بیمه روی مطالبه ذخیره میشود |
|
||
| approve | `approved_rials` (اختیاری) | submitted → approved (پیشفرض = کل ادعا) — باید `0 ≤ approved_rials ≤ total_claimed_rials` |
|
||
| reject | `reason` (**الزامی**) | submitted → rejected |
|
||
| pay | `paid_rials` (اختیاری) | approved → paid (پیشفرض = approved) — باید `0 ≤ paid_rials ≤ total_approved_rials` |
|
||
|
||
`note` (اختیاری) روی همهی اکشنها پذیرفته میشود و در تاریخچه ثبت میگردد؛ برای `reject` در نبودِ `note` خودِ `reason` ثبت میشود.
|
||
|
||
**Errors:** `422` انتقال نامعتبر، دلیل رد خالی، یا مبلغ `approved_rials`/`paid_rials` خارج از بازه (`field` در پاسخ) · `404` مطالبه یافت نشد.
|
||
|
||
> اعتبارسنجی انتقال سمت **سرور** انجام میشود (`Claim::TRANSITIONS` منبع حقیقت است)؛ مخفیکردن دکمه در UI کافی نیست.
|
||
|
||
## GET /api/v1/billing/reports/insurance-debt
|
||
گزارش بدهی بیمهها برای tenant (group بر اساس بیمه).
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": [
|
||
{ "insurance_id": 3, "insurance_name": "تأمین اجتماعی", "claimed": 840000, "approved": 800000, "paid": 500000, "debt": 340000 }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
`debt = claimed - paid` (حداقل صفر).
|
||
|
||
## GET /api/v1/my/billing/payments
|
||
«لیست پرداختها» — فهرست مسطح صورتحسابهای ثبتشدهی همان tenant (هر ردیف یک صورتحساب)، جدیدترین اول. فقط `finalized`/`paid`؛ `draft`/`void` نادیده گرفته میشوند.
|
||
|
||
**Query params:**
|
||
|
||
| param | توضیح |
|
||
|-------|-------|
|
||
| `national_code` | جستوجوی جزئی روی کد ملی بیمار (`LIKE`) — روی `profiles.national_code` و در نبود آن `users.national_code` |
|
||
| `status` | وضعیت **مشتقشدهی** پرداخت: `paid` · `partial` · `unsettled` |
|
||
| `from` / `to` | بازهی `issued_at` بر حسب ثانیهی Unix |
|
||
| `page` / `limit` | صفحهبندی (پیشفرض ۱ / ۲۰، سقف ۱۰۰) |
|
||
|
||
**Response 200** (صفحهبندیشدهی مسطح):
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{ "invoice_uuid": "…", "patient_uuid": "…", "patient_name": "دنیا خلیلی",
|
||
"national_code": "1744023654", "issued_at": 1717000000,
|
||
"amount_rials": 2350000, "paid_rials": 2350000, "status": "paid" }
|
||
],
|
||
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب · `paid_rials` = مجموع پرداختهای ثبتشدهی مراجعهی متناظر.
|
||
>
|
||
> `national_code` از `profiles.national_code` خوانده میشود (منبعِ اصلیِ کد ملی؛ بیمارِ ساختهشده در نوبتدهی کد ملی را آنجا دارد نه روی `users`)، و در نبودِ آن به `users.national_code` برمیگردد.
|
||
>
|
||
> **وضعیت پرداخت مشتق است، ذخیره نمیشود.** ستون `invoices.status` فقط چرخهی حیات صورتحساب را نگه میدارد (`draft` → `finalized` → `void`) و هرگز `paid` نمیشود؛ پول واقعی در `session_payments` ثبت میشود. قاعده در `App\Billing\Service\InvoicePaymentStatus` است:
|
||
> | حالت | شرط |
|
||
> |------|-----|
|
||
> | `paid` | `paid_rials >= amount_rials` (شامل صورتحساب صفرریالی) |
|
||
> | `partial` | `0 < paid_rials < amount_rials` |
|
||
> | `unsettled` | `paid_rials = 0` و `amount_rials > 0` |
|
||
>
|
||
> صورتحساب بدون مراجعه (`patient_session_id = null`) هیچ پرداختی ندارد، پس `unsettled` میماند.
|
||
|
||
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
|
||
|
||
## GET /api/v1/my/billing/payments/summary
|
||
خلاصهی مالی **همان مجموعهی فیلترشدهی** `GET /api/v1/my/billing/payments` — برای کارتهای آمار بالای صفحهی «لیست پرداختها». همان دامنهی رکوردها (فقط `finalized`/`paid` همان tenant).
|
||
|
||
**Query params:** دقیقاً `national_code`، `status`، `from`، `to` مثل اندپوینت لیست (بدون `page`/`limit`).
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"total_rials": 8350000,
|
||
"paid_rials": 2350000,
|
||
"unsettled_rials": 6000000,
|
||
"invoices_count": 2
|
||
}
|
||
}
|
||
```
|
||
> مبالغ جمع `patient_rials` هستند. `paid_rials` = جمع صورتحسابهایی که **کاملاً** وصول شدهاند؛ صورتحساب نیمهپرداخت (`partial`) کامل در `unsettled_rials` مینشیند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر میشود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همهی مقادیر `0` برمیگردند.
|
||
|
||
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
|
||
|
||
## GET /api/v1/my/billing/patients/{patientUuid}/invoices
|
||
«پرداختهای ثبتشده» — سربرگ بیمار + فهرست صفحهبندیشدهی صورتحسابهای `finalized`/`paid` او (جدیدترین اول). فقط مالک رکورد (همان tenant) دسترسی دارد.
|
||
|
||
**Response 200:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"patient": { "uuid": "…", "name": "دنیا خلیلی", "national_code": "1744023654" },
|
||
"data": [
|
||
{ "uuid": "…", "number": 12345, "issued_at": 1717000000, "total_rials": 2350000,
|
||
"patient_rials": 2350000, "paid_rials": 2350000,
|
||
"status": "paid", "service_title": "روکش دندان",
|
||
"items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ],
|
||
"payments": [
|
||
{ "method": "cash", "amount_rials": 350000, "paid_at": 1717000000, "created_by_name": "منشی" },
|
||
{ "method": "pos", "amount_rials": 2000000, "paid_at": 1717000500, "created_by_name": null }
|
||
] }
|
||
],
|
||
"summary": {
|
||
"total_rials": 8350000,
|
||
"paid_rials": 2350000,
|
||
"unsettled_rials": 6000000,
|
||
"invoices_count": 3
|
||
},
|
||
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
}
|
||
```
|
||
> `payments` پرداختهای ثبتشدهی مراجعهی همان صورتحساب است (قدیمیترین اول)؛ `method` یکی از `wallet` · `pos` · `cash` · `card` — برچسب فارسی سمت کلاینت در `assets/admin/lib/paymentMethods.ts`. صورتحساب بدون مراجعه آرایهی خالی میگیرد.
|
||
>
|
||
> `status` همان وضعیت مشتقشدهی بالاست (`paid` · `partial` · `unsettled`) و همیشه بر مبنای `patient_rials` سنجیده میشود، حتی در این نما که ستون مبلغش `total_rials` است. `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد).
|
||
>
|
||
> `summary` روی **همهی** صورتحسابهای همان بیمار محاسبه میشود (نه فقط صفحهی جاری) و جمع `total_rials` صورتحسابهاست — بر خلاف `summary` اندپوینت لیست پرداختها که سهم بیمار (`patient_rials`) را جمع میزند. `invoices_count` همان `meta.totalRecords` است.
|
||
|
||
**Errors:** `403` پروفایل tenant یافت نشد · `404` (`ERR_NOT_FOUND_001`) بیمار متعلق به این tenant نیست/یافت نشد.
|
||
|
||
---
|
||
|
||
## ارسال مطالبه (ClaimSubmitter)
|
||
|
||
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام میشود. پیادهسازی پیشفرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکتهای بیمهی ایران کافی است یک پیادهسازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمیکند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف میشود.
|
||
|
||
## جریان صدور فاکتور در پنل ادمین
|
||
|
||
«صدور فاکتور» (گام «جزییات» ویزارد مراجعه) و «مشاهده فاکتور» (کارت مراجعه / مودال جزئیات) هر دو از `useIssueInvoice` استفاده میکنند: `POST /billing/invoices` (idempotent) و سپس `finalize` اگر `draft` باشد. یعنی session بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.
|