24 KiB
Billing API — صورتحساب (فاز ۴ سیستم صورتحساب/بیمه)
Prefix:
/api/v1/billingدامنه:App\Billing. مرجع معماری:docs/architecture/insurance-billing-system.md. tenant از#[CurrentUser]باApp\Patient\Security\PatientRecordScopeResolverresolve میشود — همان رزولوری که پروندههای بیمار استفاده میکنند، چون صورتحساب و مطالبه از دل مراجعه بیرون میآیند و باید در همان محیط دیده شوند. محیط فعال (UserActiveContext) تعیینکننده است، نه صرفاً ترتیب نقشها. منشیِ فعال هم میتواند صورتحسابهای همان tenant را ببیند/بسازد. جزئیات جدول محیطها: patient.md.پیش از این، این دامنه ترتیب نقشها را خودش پیاده کرده بود و اول
ROLE_DOCTORرا میگرفت؛ در نتیجه مالک کلینیکی که خودش پزشک هم هست به مطب شخصیاش نگاشت میشد و صورتحساب/مطالبهی کلینیک خودش را404میگرفت. همین رزولور درInsuranceControllerهم استفاده میشود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.
صورتحساب (Invoice) از یک Encounter (PatientSession) ساخته میشود. برای هر آیتم سهم بیمهی پایه، بیمهی مکمل و بیمار با BillingCalculator محاسبه میشود:
- تعرفهی خدمت از
Tariffسال جاری (با fallback بهServiceItem.priceRials). - قانون پوشش از قرارداد بیمهی tenant (
TenantInsurance) + override خدمت (TenantServiceCoverage). - ترتیب محاسبه: کل → پوشش پایه (با سقف) → باقیمانده → پوشش مکمل → فرانشیز سهم بیمار.
نمونه: کل ۶۰۰٬۰۰۰ · پایه ۷۰٪ → ۴۲۰٬۰۰۰ · مکمل روی باقیمانده → ۱۲۰٬۰۰۰ · بیمار ۶۰٬۰۰۰.
POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده میشود (idempotent).
Permission: AUTH (مالک مراجعه)
Body:
{ "session_uuid": "…" }
Response 201:
{
"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.
{
"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؛ نمایش شمسی) |
{
"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 }):
{
"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 نمای سطحاول). فیلترها همان فیلترهای بالا.
{
"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:
{
"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 (صفحهبندیشدهی مسطح):
{
"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است:
حالت شرط paidpaid_rials >= amount_rials(شامل صورتحساب صفرریالی)partial0 < paid_rials < amount_rialsunsettledpaid_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:
{
"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:
{
"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 بدون فاکتور، در اولین مشاهده صاحب فاکتور نهاییشده میشود.