Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
438 lines
25 KiB
Markdown
438 lines
25 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`).
|
||
- درصد پوشش به تفکیک **نوع خدمت** (`ServiceItem.service_category`؛ ویزیت همیشه `outpatient`) و از زنجیرهٔ resolve توضیحدادهشده در [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model) گرفته میشود.
|
||
- ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل (با سقف) → فرانشیزِ **تکمیلی** روی سهم بیمار.
|
||
|
||
**بیمهٔ پایه صرفاً درصدی است:**
|
||
|
||
```
|
||
سهم بیمهٔ پایه = 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`.
|
||
|
||
```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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.
|