- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
48 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)
Record access model
Every endpoint in this file resolves the caller's environment through
App\Patient\Security\PatientRecordScopeResolver. The active context
(UserActiveContext, set by POST /api/v1/auth/switch-context) decides it — not the role
alone, because a doctor invited into a clinic has records in both places.
| Caller | Scope | Visible records |
|---|---|---|
| Clinic owner | clinic:<id> |
every record of the clinic |
| Doctor, active context = a clinic they belong to | clinic:<id> |
only records of their own patients in that clinic |
| Doctor, otherwise | doctor:<id> |
their personal-office records only |
| Secretary, active context = clinic | clinic:<id> |
records of the doctors assigned to that secretary |
| Secretary, active context = doctor | doctor:<id> |
that doctor's records |
"Their own patients" is derived, not stored. A clinic record is per-patient
(UNIQUE(entity_type, entity_id, user_id)) and deliberately shared between the clinic's
doctors — there is no doctor column on it and none should be added. A record counts as a
member doctor's when the patient has at least one appointment with that doctor in that
clinic. Manually created visits carry no doctor (PatientSession has no creator column),
so they never widen a member doctor's view on their own.
The member-doctor path additionally requires ClinicDoctorPermission.patients.view, and a
clinic secretary requires an active DoctorSecretary row. Both refuse when active = false,
so deactivating a doctor or secretary is the single mechanism that ends their access —
the clinic owner keeps everything, and no record is moved or deleted. A doctor whose clinic
membership was revoked silently falls back to their personal-office scope.
Read and write use the same rule. An active member doctor who can see a record can also manage it (notes, sessions, payments, attachments): the clinic record is shared by design, and per-visit ownership is not modelled, so inventing a write-only restriction on top of it would produce confusing 403s. A clinic owner who wants a read-only doctor revokes
patients.updatefor them.
A record outside the caller's scope is reported as 404 ERR_PATIENT_NOT_FOUND (not 403), so
the existence of another environment's records is never disclosed.
Endpoints
List Patients
GET /api/v1/patients
Returns a paginated list of patient records belonging to the authenticated entity, already
narrowed by the record access model — a member doctor or clinic
secretary receives only their own patients, with meta.totalRecords counted over the same
restriction.
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, و filter = active (پیشفرض — آرشیوها مخفی) | all | archived. مقادیر نامعتبر به active برمیگردند. هر session کلیدهای archived (bool) و archived_at (unix|null) را هم دارد.
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,
"gross_total_rials": 250000,
"base_insurance_rials": 20000,
"supplementary_insurance_rials": 0,
"patient_share_rials": 230000,
"final_price_rials": 230000,
"remaining_rials": 0,
"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 وقتی remaining_rials === 0 (یعنی مجموع SessionPaymentها + تخفیف به مبلغ نهایی رسیده) — نه صرفاً از روی payment_method |
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 |
تفکیک بیمه (پایا روی خود مراجعه):
| Field | Type | Notes |
|---|---|---|
gross_total_rials |
int | مبلغ کل پیش از بیمه: ویزیت + خدمات + کالاهای مصرفی |
base_insurance_rials |
int | سهم بیمه پایه |
supplementary_insurance_rials |
int | سهم بیمه تکمیلی (روی باقیمانده پس از پایه محاسبه میشود، نه روی کل) |
patient_share_rials |
int | سهم بیمار پیش از تخفیف دستی؛ همیشه برابر final_price_rials |
remaining_rials |
int | مانده: max(0, patient_share − discount_rials − paid_total_rials) |
ثابت همیشگی: gross_total_rials = base_insurance_rials + supplementary_insurance_rials + patient_share_rials
این مقادیر را سرور با
BillingCalculatorمحاسبه و رویpatient_sessionsذخیره میکند (PatientSession::applyShares). کلاینت هرگز نباید سهمها یا مانده را خودش بسازد — صفحهی تکمیل پرداخت، مودال فاکتور و داشبورد Claims همگی باید همین فیلدها را بخوانند تا اختلاف محاسباتی ممکن نباشد. مراجعات پیش از این تغییر باpatient_share = final_priceو سهم بیمه صفر backfill شدهاند.
ثبت پرداخت («تکمیل پرداخت»): از همان
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
نوبتهای همین بیمار را برمیگرداند. برای جلوگیری از نشتِ اطلاعات بین ارائهدهندهها، فقط نوبتهایی نمایش داده میشوند که با پزشک(های) خودِ صاحب پرونده گرفته شدهاند:
- ارائهدهندهی پزشک: نوبتهای بیمار با همان پزشک.
- ارائهدهندهی کلینیک (و منشیِ فعالِ کلینیک): نوبتهایی که
appointment.clinic_idآنها همین کلینیک است.
شاخهٔ کلینیک قبلاً بر اساس «پزشکانِ دارای دعوتِ پذیرفتهشده در این کلینیک» کوئری میشد؛ با پایان همکاری یا غیرفعال شدن پزشک، تاریخچهٔ نوبتهای همان کلینیک از پرونده ناپدید میشد. مبنا حالا خودِ محیطِ ثبتشدهٔ نوبت است، که تغییرناپذیر است.
مرتبشده بر اساس 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دارد.base_insurance_discount_percent/supplementary_discount_percent: ورودی محاسبه نیستند. هر مقداری که ارسال شود نادیده گرفته و از درصد قرارداد فعال (TenantInsurance.coveragePercent) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.final_price_rials(سهم بیمار) به این صورت محاسبه میشود:- ویزیت: با قاعدهی پوشش قرارداد (
TenantInsuranceService::coverageRule) از طریقBillingCalculator— همان مسیری کهInvoiceServiceبرای صدور فاکتور میرود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب میشد و با فاکتور واگرا میشد.) - هر خدمت: سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (
TenantServiceCoverageاز طریقBillingCalculator) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است. final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)وservices_total_rials = Σ(price × quantity)(قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی درconsumables_total_rialsجدا گزارش میشوند.- گیت پوشش: اگر
ServiceItem.insurance_coveredغیرفعال باشد یا برای tenant قرارداد فعالی نباشد، هیچ پوششی اعمال نمیشود و کل مبلغ سهم بیمار است. این پرچم دستی ست نمیشود؛ از ردیفهایTenantServiceCoverageسینک میشود (insurance.md). - سقف:
annual_ceiling_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"روی مراجعهی تسویهنشده، مانده (remaining_rials= مبلغ نهایی − تخفیف − پرداختهای قبلی) را از کیف پول بیمار کسر میکند و همزمان یکSessionPaymentبا روشwalletثبت میکند؛ در نتیجهis_paid=trueوpaid_atست میشود و تراکنش debit باreference: "session:{uuid}"ثبت میگردد. موجودی ناکافی →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 زمان تسویه.- آرشیو:
archived(bool) → آرشیو نرم مراجعه؛trueآن را از لیست پیشفرض (filter=active) مخفی میکند وarchived_atرا ست میکند،falseبازمیگرداند. سابقه (فاکتور/پرداختها) حذف نمیشود.
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.
Edit Session Payment
PATCH /api/v1/session/{uuid}/payments/{paymentUuid}
DELETE /api/v1/session/{uuid}/payments/{paymentUuid}
ویرایش/حذف یک پرداخت ثبتشده. پس از تغییر، فیلدهای کششدهی تسویه (payment_method، paid_at، is_paid) و paid_total_rials/patient_debt_rials بازمحاسبه میشوند. هر عملیات در Audit Log ثبت میشود.
PATCH body (همه اختیاری): { "method": "pos|cash|card", "amount_rials": 300000, "paid_at": 1770000000 }
- پرداخت
walletقابل ویرایش/حذف نیست (422— جبران تراکنش کیف پول پشتیبانی نمیشود). - مجموع پرداختها پس از ویرایش نباید از «مبلغ نهایی منهای تخفیف» بیشتر شود.
Response 200: session object با فیلدهای صورتحساب (مثل بالا).
| Code | HTTP | Description |
|---|---|---|
ERR_SESSION_NOT_FOUND |
404 | Session یافت نشد یا متعلق به owner نیست |
ERR_SESSION_PAYMENT_INVALID |
404/422 | پرداخت یافت نشد / روش نامعتبر / پرداخت wallet |
ERR_SESSION_PAYMENT_EXCEEDS |
422 | مبلغ از مانده بیشتر است |
Session Audit Log
GET /api/v1/session/{uuid}/audit-log
تاریخچهی کامل تغییرات مالی/خدماتی مراجعه (جدید → قدیم). هر رکورد:
{
"success": true,
"data": [
{ "field": "visit_price_rials", "operation": "update", "old_value": "1000000", "new_value": "900000", "actor_name": "دکتر ...", "note": null, "created_at": 1770000000 },
{ "field": "payment", "operation": "delete", "old_value": "200000", "new_value": null, "actor_name": "منشی ...", "note": "حذف پرداخت", "created_at": 1770000100 }
]
}
field یکی از: visit_price_rials | services | consumables | services_total_rials | final_price_rials | payment | discount. operation: create | update | delete. مقادیر پول ریال؛ created_at unix.
ویرایش سرویسها/کالاها/قیمت:
PATCH /api/v1/session/{uuid}علاوه بر فیلدهای قبلی، اکنونservices[]،consumables[]،visit_price_rials،insurance_base_id/insurance_supplementary_id،base_insurance_discount_percent/supplementary_discount_percentرا هم میپذیرد (بدنه مثل ایجاد سرویس). مجموعها بازمحاسبه و هر فیلد تغییرکرده در audit-log ثبت میشود.
Auto-Creation on Appointment Confirm
هر نوبتی که قطعی میشود — از هر مسیری — بهصورت خودکار:
- اگر بیمار در آن محیط پرونده نداشته باشد، یک
PatientRecordمیسازد - یک
PatientSessionگرهخورده به همان نوبت میسازد (زمان مراجعه = زمان نوبت، هزینه ویزیت و خطوط سرویس از خود نوبت snapshot میشوند)
محیط پرونده — یکی، نه هر دو (2026-07)
محیطِ رزرو تعیین میکند پرونده کجا ساخته شود، و مبنای آن ستون صریح
appointments.clinic_id است (نه استنتاج از آدرس):
appointment.clinic_id |
پرونده |
|---|---|
| مقدار دارد | فقط entity_type='clinic' همان کلینیک |
NULL |
فقط entity_type='doctor' مطب شخصی پزشک |
تغییر رفتار: پیش از این برای پزشکِ عضو کلینیک هر دو پرونده ساخته میشد و یک نوبت دو مراجعهٔ جدا داشت — یعنی درآمد یک ویزیت دو بار شمرده میشد. حالا دقیقاً یکی ساخته میشود. دادهٔ تاریخیِ تکراری حذف نشده است؛ پاکسازی آن کار جداگانهای است.
مسیرهای قطعیشدن
همهٔ اینها از AppointmentConfirmationService::onConfirmed() عبور میکنند:
| مسیر | توضیح |
|---|---|
POST /api/v1/payment/callback/{gateway} |
پرداخت آنلاین سایت عمومی (Nobat724 و سایتهای وابسته) |
PATCH /api/v1/appointment/{uuid}/status |
تغییر وضعیت به confirmed |
PATCH /api/v1/appointment/{uuid} |
ویرایش نوبت همراه با تغییر وضعیت |
POST /api/v1/my/appointment |
رزرو از پنل — نوبت مستقیم confirmed ثبت میشود |
POST /api/v1/admin/appointment |
رزرو از ادمین — نوبت مستقیم confirmed ثبت میشود |
قواعد
- idempotent: قطعیشدن دوباره (
confirmed → cancelled → confirmed) مراجعهٔ تکراری نمیسازد. مراجعهٔ آرشیوشده هم «ساختهشده» حساب میشود. - نوبت رزروِ روز-محور (
is_reserve=true) ساعت مشخص ندارد و مراجعه نمیسازد. - گارد اشتراک: بدون ویژگی
patient_recordsبرای آن tenant، پرونده ساخته نمیشود و خطا هم برنمیگردد (فقط لاگ سطحinfo). - شکست ساخت پرونده، قطعیشدن نوبت یا تأیید پرداخت را برنمیگرداند — لاگ سطح
errorثبت میشود و پرونده را میتوان بعداً باphp bin/console app:appointment:backfill-sessions --fixساخت. این command نوبتهایconfirmed/completedبدون مراجعه را فهرست (و با--fixتکمیل) میکند.
انتساب پزشک: هر 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 |
رکورد/تماس یافت نشد یا مالک دیگر |