Backend:
- Add profile columns field_of_study, province_id, city_id, postal_code,
referral_source (UserProfile + migration).
- Extend PATCH /api/v1/patient/{uuid} to persist all demographic fields
and return them in the patient profile payload.
- Support editable mobile (login identifier): validation, uniqueness,
User.setMobileNumber, new ERR_PROFILE_002.
- Update docs/api/patient.md.
Frontend:
- New reusable Input, Field, and PatientRecordInfoForm (RHF + Zod).
- usePatient/useUpdatePatient hooks and patientForm mapping helpers.
- Extend the existing "info" tab in MyPatientsPage to the full field set
via the shared form (province/city/insurance options, Jalali date).
Tests: Patient entity + PATCH integration (PHPUnit); form, hooks, and
mapping helpers (Vitest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 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 or phone |
Response 200:
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "علی رضایی", "phone": "09123456789" },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
],
"meta": {
"totalRecords": 42,
"totalPages": 3,
"currentPage": 1
}
}
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 (اختیاری، ۱۰ رقم)"
}
- اگر
user_uuidوmobileهر دو خالی باشند → خطا. 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",
"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 هستند (نوبت خدمت/قیمت مستقل ندارد).
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": "...",
"services": [
{
"service_item_uuid": "...",
"staff_uuid": null,
"quantity": 2
}
]
}
Field notes:
payment_method:cash|card|insurance|online|pendingservices: 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)(قیمت کامل خدمات، بدون بیمه).- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیشنمایش پنل هم همین قاعده را سمت کلاینت آینه میکند.
- ویزیت:
اتصال خودکار مطالبهی بیمه: اگر 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 |
Update Session
PATCH /api/v1/session/{uuid}
Updates mutable fields on a session.
Request body (all optional):
{
"notes": "...",
"payment_method": "card"
}
Response 200:
{
"success": true,
"data": { ...session object... }
}
Errors:
| Code | HTTP | Description |
|---|---|---|
ERR_SESSION_NOT_FOUND |
404 | Session not found or not owned |
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.