Extend PatientService::applyDiscount to carry the source rule id/label and add applyDiscountRule, which computes the rial amount via the engine (DiscountEngine::computeForRule now public) and records the rule for audit. updateSession accepts discount_rule_uuid (owner-scoped, takes precedence over the manual discount; ''/null clears). Verified end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
37 KiB
Patient Records & Sessions API
Overview
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the patient_records feature.
Base path: /api/v1
Auth: Bearer JWT (doctor, clinic, or secretary with appointments.view permission required)
Endpoints
List Patients
GET /api/v1/patients
Returns a paginated list of patient records belonging to the authenticated entity.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number |
limit |
int | 20 | Items per page (10–50) |
search |
string | — | Search by patient name, phone or national code |
tags |
string | — | Comma-separated tenant-tag uuids; matches records having any of them |
gender |
string | — | Patient UserProfile.gender (e.g. male/female) |
insurance_id |
int | — | Patient's basic insurance id (UserProfile.basic_insurance_id) |
admitted_from / admitted_to |
int | — | Record creation (تاریخ پذیرش) unix-seconds range |
service_status |
string | — | pending (has an unpaid session) or completed (has sessions, none unpaid) |
has_debt |
bool | — | 1 → only records with an unpaid session (payment_method='pending') |
«بدهی» و «وضعیت سرویس» بر پایهی وجود مراجعهی پرداختنشده تعریف شدهاند (مدل بدهی مستقل ندارد). فیلترها روی هم AND میشوند و در count هم اعمال میگردند.
Response 200:
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user_uuid": "...",
"user_name": "علی رضایی",
"user_mobile": "09123456789",
"user_national_code": "0012345675",
"record_number": "1024",
"tags": [],
"created_by_type": "doctor",
"created_at": 1718375000
}
],
"meta": {
"totalRecords": 42,
"totalPages": 3,
"currentPage": 1
}
}
user_national_codeمنبعِ حقیقتش جدولprofilesاست (نهusers). اگر روی خودِ کاربر خالی باشد، از پروفایل پر میشود؛ اگر هیچکدام نداشته باشندnullاست.
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_SUBSCRIPTION_REQUIRED |
403 | No active plan with patient_records feature |
Create Patient Record
POST /api/v1/patient
Creates a patient record for a user under the current entity. If the record already exists, returns the existing record (idempotent).
سه حالت پشتیبانی میشود:
- کاربر ثبتنامکرده با uuid:
user_uuidارسال شود. - کاربر ثبتنامکرده با موبایل:
mobileارسال شود (کاربر موجود پیدا میشود). - بیمار جدید بدون ثبتنام:
mobile+nameارسال شود؛ اگر کاربری با آن موبایل نباشد،Userجدید (نقشROLE_USER، بدون رمز عبور) ساخته میشود سپس پرونده.
Request body:
{
"user_uuid": "string (اختیاری)",
"mobile": "09xxxxxxxxx (اختیاری — برای جستجو یا ساخت بیمار جدید)",
"name": "string (الزامی فقط هنگام ساخت بیمار جدید)",
"national_code": "string (اختیاری، ۱۰ رقم)",
"record_number": "string (اختیاری) — شماره پرونده، مخصوص رکورد",
"tags": ["uuid برچسبهای TenantTag (اختیاری) — باید متعلق به همین tenant باشند"]
}
- اگر
user_uuidوmobileهر دو خالی باشند → خطا. record_numberوtagsروی خودِ رکورد ذخیره میشوند (نه پروفایل کاربر). سایر مشخصات دموگرافیک (gender,date_of_birth,referral_source,description, بیمهها) رویUserProfileهستند و از طریقPATCH /patient/{uuid}ست میشوند. پاسخ همیشهrecord_numberوtags: [{uuid,name,color}]را برمیگرداند.- برچسب متعلق به tenant دیگر →
422 ERR_VALIDATION_001(field: tags). national_codeفقط وقتی روی کاربر ست میشود که کاربر کد ملی نداشته باشد.- موبایل تکراری duplicate نمیسازد؛ همان کاربر استفاده میشود.
- یکتایی کد ملی: اگر
national_codeارسالی قبلاً به پروفایل کاربر دیگری تعلق داشته باشد →409با کدERR_PROFILE_001(field: national_code). پیام خطا شامل شماره موبایلِ ماسکشدهی صاحب کد است (مثلاً «این کد ملی قبلاً با شماره 0912****56 ثبت شده است»). یک کد ملی = یک بیمار در کل سیستم (همراستا با قید یکتایprofiles.national_code).
Response 201:
{
"success": true,
"data": {
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "...", "phone": "..." },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
}
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | user_uuid/mobile خالی، یا موبایل/کد ملی نامعتبر، یا نام برای بیمار جدید خالی |
ERR_PROFILE_001 |
409 | کد ملی قبلاً برای پروفایل کاربر دیگری ثبت شده (field: national_code) |
ERR_SUBSCRIPTION_REQUIRED |
403 | No patient_records feature |
Get Patient Record
GET /api/v1/patient/{uuid}
Returns a single patient record, enriched with the patient's full profile (profile) درونخطی از UserProfile. اگر پروفایل وجود نداشت، فیلدها null برمیگردند (نه خطا). نام بیمهها از روی id resolve میشوند.
Response 200:
{
"success": true,
"data": {
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user_uuid": "...",
"user_name": "...",
"user_mobile": "0912...",
"user_national_code": "...",
"created_at": 1718375000,
"profile": {
"full_name": "محمد محمدی",
"name": "محمد",
"family": "محمدی",
"fathers_name": "رضا",
"national_code": "0012345678",
"gender": "male",
"date_of_birth": 700000000,
"blood_type": "O+",
"marital_status": "single",
"education": "کارشناسی",
"field_of_study": "نرمافزار",
"job": "...",
"address": "...",
"province_id": 8,
"city_id": 42,
"postal_code": "8913746351",
"referral_source": "اینستاگرام",
"description": "...",
"home_phone": "...",
"work_phone": "...",
"mobile": "0912...",
"basic_insurance_id": 3,
"basic_insurance_name": "تأمین اجتماعی",
"supplementary_insurance_id": 9,
"supplementary_insurance_name": "دانا"
}
}
}
date_of_birthیک Unix timestamp است؛ سمت کلاینت باformatDate()شمسی نمایش داده میشود.profileبرای هر دوentity_type(doctor/clinic) یکسان است.
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_PATIENT_NOT_FOUND |
404 | Record not found or not owned by caller |
ERR_SUBSCRIPTION_REQUIRED |
403 | No patient_records feature |
Update Patient Basic Info
PATCH /api/v1/patient/{uuid}
اطلاعات پایهی بیمار را بهروزرسانی میکند. برای هر سه نقشِ صاحبِ پرونده در دسترس است: پزشک، کلینیک، و منشیِ فعالِ همان مطب/کلینیک (دسترسی از طریق همان resolveEntity + assertPatientGate مثل بقیهی endpointهای بیمار کنترل میشود؛ منشی باید db_uuid فعال داشته باشد).
بهروزرسانی partial است — فقط کلیدهای ارسالشده اعمال میشوند. مقدار ""/null برای فیلدهای پروفایل یعنی «پاککردن». name روی User.realName و بقیهی فیلدها روی UserProfile مینشینند (در صورت نبود پروفایل، ساخته میشود).
شماره موبایل قابل ویرایش است — موبایل همان شناسهی ورود کاربر است، پس ارسال
mobileعلاوه بر شمارهی تماس، نامکاربری ورود کاربر را نیز تغییر میدهد. باید^09\d{9}$و در سطح کاربران یکتا باشد.
Request body:
{
"name": "محمد",
"family": "محمدی",
"fathers_name": "رضا",
"mobile": "09131234567",
"national_code": "0012345678",
"gender": "male",
"blood_type": "O+",
"marital_status": "single",
"education": "کارشناسی",
"field_of_study": "نرمافزار",
"job": "مهندس",
"home_phone": "03511111111",
"work_phone": "03512222222",
"address": "...",
"province_id": 8,
"city_id": 42,
"postal_code": "8913746351",
"referral_source": "اینستاگرام",
"description": "...",
"basic_insurance_id": 3,
"supplementary_insurance_id": 9
}
| Field | Type | Notes |
|---|---|---|
name |
string | اگر ارسال شود و خالی نباشد → User.realName. رشتهی خالی نادیده گرفته میشود. |
family |
string|null | UserProfile.family |
fathers_name |
string|null | UserProfile.fathersName (نام پدر) |
mobile |
string|null | اگر خالی نباشد و با موبایل فعلی فرق کند باید ^09\d{9}$ و یکتا باشد؛ روی User.mobileNumber ست میشود و شناسهی ورود را عوض میکند |
national_code |
string|null | اگر خالی نباشد باید ۱۰ رقم و در سطح بیمار یکتا باشد؛ روی User.nationalCode و UserProfile.nationalCode ست میشود |
gender |
male|female|null |
|
blood_type |
string|null | |
marital_status |
string|null | |
education |
string|null | مقطع تحصیلی (UserProfile.education) |
field_of_study |
string|null | رشتهی تحصیلی (UserProfile.fieldOfStudy) |
job |
string|null | |
home_phone, work_phone |
string|null | |
address |
string|null | |
province_id, city_id |
int|null | id استان/شهر (category؛ null = حذف) |
postal_code |
string|null | کد پستی |
referral_source |
string|null | نحوهی آشنایی |
description |
string|null | توضیحات |
basic_insurance_id, supplementary_insurance_id |
int|null | id بیمه؛ null = حذف |
Response 200: مثل GET /api/v1/patient/{uuid} (رکورد + profile تازه).
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_PATIENT_NOT_FOUND |
404 | Record not found or not owned by caller |
ERR_VALIDATION_001 |
422 | کد ملی باید ۱۰ رقم باشد (field: national_code) یا موبایل نامعتبر است (field: mobile) |
ERR_PROFILE_NATIONAL_CODE_TAKEN |
409 | کد ملی متعلق به بیمار دیگری است (field: national_code) |
ERR_PROFILE_002 |
409 | موبایل متعلق به کاربر دیگری است (field: mobile) |
ERR_SUBSCRIPTION_REQUIRED |
403 | No patient_records feature |
List Patient Sessions
GET /api/v1/patient/{uuid}/sessions
Returns paginated sessions for a patient record.
Query params: page, limit (same as list)
Response 200:
{
"success": true,
"data": [
{
"uuid": "...",
"record_uuid": "...",
"appointment_uuid": null,
"insurance_base_id": null,
"insurance_supplementary_id": null,
"visit_price_rials": 200000,
"base_insurance_discount_percent": "10.00",
"supplementary_discount_percent": "5.00",
"services_total_rials": 50000,
"final_price_rials": 230000,
"payment_method": "cash",
"is_paid": true,
"services": [
{
"uuid": "...",
"service_item_uuid": "...",
"service_name": "کندلا ۲۰۲۱",
"staff_uuid": "...",
"staff_name": "ژیلا فتحی",
"price_rials": 50000,
"quantity": 1,
"line_total_rials": 50000,
"created_at": 1718375000
}
],
"invoice_uuid": "...",
"invoice_status": "finalized",
"patient_debt_rials": 0,
"notes": "...",
"created_at": 1718375000,
"updated_at": 1718375000
}
],
"meta": { "totalRecords": 8, "totalPages": 1, "currentPage": 1 }
}
فیلدهای غنیسازیشده (برای تب «سرویسها»):
| Field | Type | Notes |
|---|---|---|
is_paid |
bool | true وقتی payment_method !== "pending" |
services |
array | سرویسهای ثبتشده در این session (نام، انجامدهنده/staff, قیمت، تعداد) |
invoice_uuid |
string|null | uuid فاکتور مرتبط (اگر ساخته شده باشد؛ برای «مشاهده فاکتور») |
invoice_status |
string|null | draft|finalized|paid|void |
patient_debt_rials |
int | مانده بدهی سهم بیمار؛ 0 اگر تسویه شده، وگرنه سهم بیمارِ فاکتور یا final_price_rials |
ثبت پرداخت («تکمیل پرداخت»): از همان
PATCH /api/v1/session/{uuid}با بدنهی{ "payment_method": "cash" }استفاده میشود؛ پس از آنis_paid=trueوpatient_debt_rials=0میشود. مشاهدهی فاکتور ازGET /api/v1/billing/invoices/{invoice_uuid}(این endpoint اکنون برای منشیِ فعال هم در دسترس است).
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_PATIENT_NOT_FOUND |
404 | Record not found or not owned by caller |
ERR_SUBSCRIPTION_REQUIRED |
403 | No patient_records feature |
List Patient Appointments
GET /api/v1/patient/{uuid}/appointments
نوبتهای همین بیمار را برمیگرداند. برای جلوگیری از نشتِ اطلاعات بین ارائهدهندهها، فقط نوبتهایی نمایش داده میشوند که با پزشک(های) خودِ صاحب پرونده گرفته شدهاند:
- ارائهدهندهی پزشک: نوبتهای بیمار با همان پزشک.
- ارائهدهندهی کلینیک (و منشیِ فعالِ کلینیک): نوبتهای بیمار با پزشکانی که دعوت پذیرفتهشده (
accepted) در آن کلینیک دارند.
مرتبشده بر اساس starts_at نزولی. خروجی آرایهی ساده است (بدون صفحهبندی).
Response 200:
{
"success": true,
"data": [
{
"uuid": "…",
"starts_at": 1754000000,
"ends_at": 1754001800,
"status": "confirmed",
"version": 1,
"doctor_name": "دکتر ژیلا فتحی",
"service_name": null,
"price_rials": null,
"created_at": 1754000000
}
]
}
status یکی از: pending، confirmed، completed، cancelled_by_doctor، cancelled_by_user، no_show، expired. فیلدهای service_name/price_rials فعلاً همیشه null هستند (نوبت خدمت/قیمت مستقل ندارد). version نسخهٔ خوشبینانهٔ (optimistic-lock) نوبت است و برای فراخوانی PATCH /api/v1/appointment/{uuid}/status لازم است.
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_PATIENT_NOT_FOUND |
404 | Record not found or not owned by caller |
ERR_SUBSCRIPTION_REQUIRED |
403 | No patient_records feature |
Create Session
POST /api/v1/patient/{uuid}/session
Creates a new visit session for a patient record.
Request body:
{
"visit_price_rials": 200000,
"base_insurance_discount_percent": 10,
"supplementary_discount_percent": 5,
"insurance_base_id": null,
"insurance_supplementary_id": null,
"payment_method": "cash",
"notes": "...",
"session_at": 1760000000,
"inventory_package_uuid": null,
"services": [
{
"service_item_uuid": "...",
"staff_uuid": null,
"quantity": 2
}
],
"consumables": [
{
"inventory_item_uuid": "...",
"quantity": 2
}
]
}
Field notes:
visit_price_rials: هزینه ویزیت (ریال). بهطور پیشفرض اختیاری (پیشفرض0)؛ اگر فلگrequire_visit_priceدر insurance-pricing برای tenant فعال باشد، مقدار> 0الزامی است.payment_method:cash|card|insurance|online|pendingsession_at(اختیاری): زمان پذیرش (unix)؛ اگر نیایدnullمیماند و زمان ثبت (created_at) مبنا است.inventory_package_uuid(اختیاری): مرجع پکیج مصرفی (inventory)؛ فقط پکیج متعلق به همان tenant پذیرفته میشود، وگرنه بیصدا نادیده گرفته میشود. روی قیمت اثری ندارد (فقط مرجع).consumables(اختیاری): کالاهای مصرفی از انبار (inventory).price_rialssnapshot ازInventoryItem.price؛quantity(پیشفرض ۱، حداقل ۱). کالاها پوشش بیمه ندارند و مبلغ کاملشان بهfinal_price_rials(سهم بیمار) اضافه میشود. آیتم ناموجود یا متعلق به tenant دیگر بیصدا رد میشود (همرفتار باservices). پاسخ شاملconsumables[](باline_total_rials) وconsumables_total_rialsاست.services: array of service items to attach;price_rialssnapshot از ServiceItem؛quantity(پیشفرض ۱) →line_total_rials = price_rials × quantity. هرSessionServiceدر پاسخquantityوline_total_rialsدارد.final_price_rials(سهم بیمار) به این صورت محاسبه میشود:- ویزیت:
round(visit_price × (1 - base%) × (1 - supp%))با درصدهای انتخابشده در فرم. - هر خدمت: سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (
TenantServiceCoverageاز طریقBillingCalculator) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است. final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)وservices_total_rials = Σ(price × quantity)(قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی درconsumables_total_rialsجدا گزارش میشوند.- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیشنمایش پنل هم همین قاعده را سمت کلاینت آینه میکند.
- ویزیت:
اتصال خودکار مطالبهی بیمه: اگر session دارای insurance_base_id یا insurance_supplementary_id باشد، پس از ثبت بهصورت خودکار صورتحساب ساخته و نهایی میشود و مطالبه(های) بیمه در وضعیت pending ایجاد میگردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحهی مطالبات بیمه قابل پیگیری و ارسالاند. خطا در این مرحله ثبت session را خراب نمیکند (لاگ میشود). برای هر صورتحساب فقط یکبار مطالبه ساخته میشود.
Response 201:
{
"success": true,
"data": { ...session object... }
}
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_PATIENT_NOT_FOUND |
404 | Record not found or not owned |
ERR_SUBSCRIPTION_REQUIRED |
403 | No patient_records feature |
ERR_VALIDATION_001 |
422 | فلگ require_visit_price فعال است ولی visit_price_rials <= 0 (field: visit_price_rials) |
Update Session
PATCH /api/v1/session/{uuid}
Updates mutable fields on a session.
Request body (all optional):
{
"notes": "...",
"payment_method": "card",
"paid_at": 1770000000,
"discount_type": "percent",
"discount_value": 25
}
payment_method: "wallet"روی مراجعهی تسویهنشده، کل مبلغ نهایی را از کیف پول بیمار کسر میکند (موجودی ناکافی →422 ERR_WALLET_INSUFFICIENT).- تخفیف تسویه (دستی):
discount_type=percent(۰..۱۰۰) یاfixed(ریال، حداکثر برابر مبلغ نهایی) یاnull(حذف تخفیف). مبلغ محاسبهشده درdiscount_rialsبرمیگردد. تخفیف نمیتواند از «مبلغ نهایی منهای پرداختهای ثبتشده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویهشده میکند (is_paid,paid_at). - تخفیف بر اساس قانون:
discount_rule_uuid(رشته) → قانون تخفیف (owner-scoped) اعمال میشود؛ مقدار ریالی از خود قانون توسط موتور محاسبه میگردد (نوع/مبنا بر اساس قانون).''/null→ حذف تخفیف. اولویت برdiscount_typeدستی. قانونِ نامعتبر →404. منبع اعمالشده در پاسخ بهصورتapplied_discount_rule_idوapplied_discount_rule_label(audit) برمیگردد. قوانین قابلاعمال ازGET /api/v1/session/{uuid}/discount-suggestions(نگاه کنید بهdiscount.md). paid_at: unix timestamp زمان تسویه.
Response 200:
{
"success": true,
"data": { ...session object... }
}
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_SESSION_NOT_FOUND |
404 | Session not found or not owned |
ERR_SESSION_DISCOUNT_INVALID |
422 | نوع/مقدار تخفیف نامعتبر یا بیش از سقف |
ERR_WALLET_INSUFFICIENT |
422 | موجودی کیف پول کافی نیست (روش wallet) |
Add Session Payment (تسویه چندتکه)
POST /api/v1/session/{uuid}/payments
ثبت یک پرداخت جزئی روی مراجعه. مجموع پرداختها + تخفیف که به مبلغ نهایی برسد، مراجعه تسویهشده میشود (is_paid=true، payment_method = روش آخرین پرداخت، paid_at ست میشود).
Request body:
{
"method": "wallet | pos | cash | card",
"amount_rials": 200000,
"paid_at": 1770000000
}
method: "wallet"همان مبلغ را از کیف پول بیمار کسر میکند (تراکنش debit باreference: "session:{uuid}").paid_atاختیاری است (پیشفرض: اکنون).
Response 201: session object با فیلدهای صورتحساب:
{
"success": true,
"data": {
"...": "...session fields...",
"discount_type": "fixed",
"discount_value": 100000,
"discount_rials": 100000,
"paid_total_rials": 300000,
"patient_debt_rials": 0,
"paid_at": 1770000000,
"payments": [
{ "uuid": "...", "method": "cash", "amount_rials": 200000, "paid_at": 1770000000, "created_by_name": "...", "created_at": 1770000000 }
]
}
}
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_SESSION_NOT_FOUND |
404 | Session not found or not owned |
ERR_SESSION_PAYMENT_INVALID |
422 | روش نامعتبر یا مبلغ ≤ ۰ |
ERR_SESSION_PAYMENT_EXCEEDS |
422 | مبلغ از مانده بدهی بیشتر است |
ERR_WALLET_INSUFFICIENT |
422 | موجودی کیف پول کافی نیست (روش wallet) |
Session list debt: در GET /api/v1/patient/{uuid}/sessions، فیلد patient_debt_rials = سهم بیمار (از فاکتور در صورت وجود) منهای discount_rials و paid_total_rials.
Auto-Creation on Appointment Confirm
When an appointment's status changes to confirmed via PATCH /api/v1/appointment/{uuid}/status, the system automatically:
- Creates a
PatientRecordfor the appointment's user (if not already existing) under the doctor entity - Creates a blank
PatientSessionlinked to the appointment - اگر نوبت با آدرس کلینیک ثبت شده باشد (
appointment.address_id→DoctorAddress.clinic_id)، همان دو مرحله برای آن کلینیک (entity_type='clinic') هم تکرار میشود. اگر آدرس نوبت کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه میشود.
هر شاخه (doctor / clinic) مستقل و فقط در صورت فعالبودن ویژگی patient_records برای همان entity اجرا میشود. duplicate با findByEntityAndUser جلوگیری میشود.
انتساب پزشک: هر PatientSession در پاسخ، doctor_uuid و doctor_name را از روی نوبتِ متناظر برمیگرداند؛ پس در پروندهی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است.
آدرس نوبت: هنگام رزرو، address_id خودکار از location_id همان session برنامهی هفتگی ست میشود (در همهی مسیرهای رزرو). ثبت location_id برای هر شیفت فعال در برنامهی هفتگی الزامی است (POST/PATCH /api/v1/appointment-settings/weekly-schedule)؛ در غیر این صورت 422.
ضمیمههای بیمار (Attachments)
فایلهای پیوستِ یک پرونده. همه scope به رکورد و tenant صاحب رکورد.
Permission: IS_AUTHENTICATED_FULLY (doctor/clinic/secretary مالک رکورد)
GET /api/v1/patient/{uuid}/attachments
لیست ضمیمهها. Response: { success, data: [{ uuid, name, url, mime, size, created_at }] }
POST /api/v1/patient/{uuid}/attachment
آپلود فایل بهصورت raw body (مثل سایر /file/upload/...): بدنه = بایتهای فایل، هدر Content-Disposition: attachment; filename="...". نام نمایشی اختیاری از query ?name=. فایل زیر public/uploads/patients/attachments/YYYY-MM/ ذخیره میشود. Response 201: attachment object.
DELETE /api/v1/patient/attachment/{uuid}
حذف ضمیمه. فقط مالک رکورد؛ در غیر این صورت 404.
Errors
| HTTP | Code | Description |
|---|---|---|
| 404 | ERR_PATIENT_001 / ERR_NOT_FOUND_001 |
رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر |
| 422 | ERR_VALIDATION_001 |
فایل نامعتبر |
پرونده پزشکی (Medical Records)
معاینات/یادداشتهای پزشکیِ یک پرونده. scope به رکورد و tenant صاحب رکورد.
Permission: IS_AUTHENTICATED_FULLY (مالک رکورد)
GET /api/v1/patient/{uuid}/medical-records
لیست (مرتب بر اساس recorded_at نزولی). Response: { success, data: [{ uuid, title, body, recorded_at, created_at }] }
POST /api/v1/patient/{uuid}/medical-record
{ "title": "معاینه اولیه", "body": "شرح (اختیاری)", "recorded_at": 1700000000 }
title الزامی؛ recorded_at اختیاری (پیشفرض زمان ثبت). Response 201.
PATCH /api/v1/patient/medical-record/{uuid}
فیلدهای اختیاری title / body / recorded_at. فقط مالک؛ در غیر این صورت 404.
DELETE /api/v1/patient/medical-record/{uuid}
حذف. فقط مالک؛ در غیر این صورت 404.
Errors
| HTTP | Code | Description |
|---|---|---|
| 422 | ERR_VALIDATION_001 |
عنوان خالی (field: title) |
| 404 | ERR_PATIENT_001 / ERR_NOT_FOUND_001 |
رکورد/رکورد پزشکی یافت نشد یا tenant دیگر |
پیامهای بیمار (Messages)
لاگ پیامها/ارتباطات با بیمار (SMS/یادداشت/تماس). scope به رکورد و tenant.
Permission: IS_AUTHENTICATED_FULLY (مالک رکورد)
GET /api/v1/patient/{uuid}/messages
لیست (جدیدترین اول). Response: { success, data: [{ uuid, body, channel, created_at }] }
POST /api/v1/patient/{uuid}/message
{ "body": "متن پیام", "channel": "sms|note|call|email (اختیاری، پیشفرض sms)" }
body الزامی؛ channel نامعتبر → sms. Response 201.
DELETE /api/v1/patient/message/{uuid}
حذف. فقط مالک؛ در غیر این صورت 404.
Errors
| HTTP | Code | Description |
|---|---|---|
| 422 | ERR_VALIDATION_001 |
متن خالی (field: body) |
| 404 | ERR_PATIENT_001 / ERR_NOT_FOUND_001 |
رکورد/پیام یافت نشد یا tenant دیگر |
توجه: پنل ادمین دیگر تب «پیامها» را نشان نمیدهد؛ جای آن «یادداشتها» آمده است. این اندپوینتها باقی میمانند ولی توسط پنل مصرف نمیشوند.
یادداشتهای بیمار (Notes)
یادداشتهای شخصیِ پرسنل روی پرونده، پینشدنی. مشترک بین همهی کارکنانِ صاحبِ پرونده؛ نام سازنده هنگام ثبت ذخیره میشود (پس از حذف کاربر هم باقی میماند). scope به رکورد و tenant.
Permission: IS_AUTHENTICATED_FULLY (مالک رکورد)
GET /api/v1/patient/{uuid}/notes
لیست، پینشدهها اول، سپس جدیدترین. Response: { success, data: [{ uuid, body, pinned, author, created_at, updated_at }] }
POST /api/v1/patient/{uuid}/note
{ "body": "متن یادداشت", "pinned": false }
body الزامی (trim)؛ pinned اختیاری (پیشفرض false). author/سازنده سمت سرور از کاربر جاری (real_name یا موبایل) پر میشود. Response 201.
PATCH /api/v1/patient/note/{uuid}
{ "body": "متن جدید", "pinned": true }
هر دو فیلد اختیاری (partial). با ارسال pinned تنها → toggle پین بدون تغییر متن. body خالی → 422. updated_at ست میشود. فقط مالک.
DELETE /api/v1/patient/note/{uuid}
حذف. فقط مالک؛ در غیر این صورت 404.
Errors
| HTTP | Code | Description |
|---|---|---|
| 422 | ERR_VALIDATION_001 |
متن خالی (field: body) |
| 404 | ERR_PATIENT_001 / ERR_NOT_FOUND_001 |
رکورد/یادداشت یافت نشد یا tenant دیگر |
مالی بیمار (Financials: پرداخت / تراکنش / کیفپول)
مالیِ کاربرِ صاحبِ رکورد (بیمار)، gate شده به مالکیت رکورد. اندپوینتهای عمومی wallet/* و my/payments به #[CurrentUser] (پولِ خودِ درخواستکننده) بستهاند؛ این اندپوینتها مالیِ بیمار را برای دکتر/منشیِ صاحب پرونده برمیگردانند.
Permission: IS_AUTHENTICATED_FULLY (مالک رکورد)
GET /api/v1/patient/{uuid}/payments
لیست پرداختهای درگاهیِ بیمار (paginated). Query: page, limit (≤100)، status (اختیاری: pending|success|failed|canceled|refunded).
Response: { success, data: [{ uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at }], meta: { totalRecords, totalPages, currentPage } }
هر تراکنش برای شفافیت این فیلدها را دارد: type (credit/debit)، payment_method (card/pos/cash/gateway/wallet یا null)، description (دلیل)، reference (مرجعِ ماشینی مثل session:{uuid})، created_by_name (کاربرِ ثبتکننده)، status (confirmed)، balance_after، created_at.
GET /api/v1/patient/{uuid}/wallet
موجودی + ۱۰ تراکنش اخیر (تب کیفپول). balance_rials = مجموع credit − debit.
Response: { success, data: { balance_rials, recent_transactions: [{ uuid, amount_rials, type, description, balance_after, created_by_name, payment_method, reference, status, created_at }] } }
POST /api/v1/patient/{uuid}/wallet/charge
شارژ دستی کیفپول (مثلاً بیعانهٔ حضوری). یک تراکنش credit برای کاربرِ صاحب رکورد میسازد؛ کاربرِ درخواستکننده بهعنوان created_by ثبت میشود.
{ "amount_rials": 300000, "description": "بیعانه نوبت (اختیاری)", "payment_method": "card", "reference": "(اختیاری)" }
amount_rials باید > 0 باشد وگرنه 422. payment_method ناشناخته نادیده گرفته میشود (null). Response 201: { success, data: { transaction, balance_rials } }
POST /api/v1/patient/{uuid}/wallet/withdraw
برداشت دستی از کیفپول (مثلاً عودت وجه حضوری). یک تراکنش debit با ثبتِ کاربرِ عامل و روش پرداخت میسازد.
{ "amount_rials": 200000, "description": "عودت (اختیاری)", "payment_method": "cash" }
amount_rials باید > 0 باشد وگرنه 422. اگر مبلغ از موجودی فعلی بیشتر باشد 422 با کد ERR_WALLET_INSUFFICIENT. Response 201: { success, data: { transaction, balance_rials } }
PATCH /api/v1/session/{uuid} — پرداخت مراجعه از کیف پول
با {"payment_method": "wallet"} سهمِ نهاییِ بیمار (final_price_rials) از کیف پول کسر میشود: یک تراکنشِ debit با payment_method=wallet، reference=session:{uuid} و دلیلِ «پرداخت سرویس: …» ثبت میگردد. فقط وقتی مراجعه هنوز تسویه نشده و مبلغ > 0 باشد. موجودیِ ناکافی → 422 ERR_WALLET_INSUFFICIENT (مراجعه تسویه نمیشود).
GET /api/v1/patient/{uuid}/wallet/transactions
دفترِ کاملِ تراکنشهای کیفپول (paginated). Query: page, limit (≤100).
Response: { success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }
Errors
| HTTP | Code | Description |
|---|---|---|
| 404 | ERR_PATIENT_001 |
رکورد یافت نشد یا متعلق به مالک دیگر |
| 422 | ERR_VALIDATION_001 |
مبلغ شارژ/برداشت ≤ 0 |
| 422 | ERR_WALLET_INSUFFICIENT |
مبلغ برداشت از موجودی کیفپول بیشتر است |
کال سنتر بیمار (Call Center)
لاگ تماسهای تلفنی با بیمار (تب «کال سنتر»). scope به رکورد و مالک.
Permission: IS_AUTHENTICATED_FULLY (مالک رکورد)
GET /api/v1/patient/{uuid}/calls
لیست (جدیدترین بر اساس called_at). Query: outcome (اختیاری: success|missed).
Response: { success, data: [{ uuid, subject, summary, outcome, called_at, personnel, created_at }] }
POST /api/v1/patient/{uuid}/call
{ "subject": "پیگیری نوبت", "summary": "اختیاری", "outcome": "success|missed (پیشفرض success)", "called_at": 1731000000, "personnel": "نام ثبتکننده (اختیاری)" }
subject الزامی؛ outcome نامعتبر → success؛ called_at غایب → اکنون. Response 201.
DELETE /api/v1/patient/call/{uuid}
حذف. فقط مالک؛ در غیر این صورت 404.
Errors
| HTTP | Code | Description |
|---|---|---|
| 422 | ERR_VALIDATION_001 |
موضوع خالی (field: subject) |
| 404 | ERR_PATIENT_001 |
رکورد/تماس یافت نشد یا مالک دیگر |