Files
clinicpro/docs/api/patient.md
T
hamedandClaude Opus 4.8 15c6f5dce7 feat(patients): phase A — records list + create/edit form (Figma)
Rebuild the patient records (پرونده‌ها) area, phase A of the Figma redesign:

- BE: add a clinic-scoped `record_number` and a TenantTag `tags` M2M to
  PatientRecord (migration + EAGER-hydrated collection). POST /patient and
  PATCH /patient/{uuid} now accept `record_number` and tenant-scoped `tags`
  (foreign tag → 422); demographic fields (gender, date_of_birth,
  referral_source, description) continue to live on UserProfile via PATCH.
- FE: new PatientsListPage (table + card views, search, pagination, tags
  column, "تشکیل پرونده") at /admin/patients, and PatientRecordFormPage
  (create/edit) that POSTs the record then PATCHes the demographics. Point
  the sidebar "پرونده" entry to the new list.

Phases B–E (tabbed patient file, service stepper, invoice, payments/wallet,
call-center) follow. Backend covered by PHPUnit, FE by Vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:54:38 +03:30

20 KiB
Raw Blame History

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 (1050)
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).

سه حالت پشتیبانی می‌شود:

  1. کاربر ثبت‌نام‌کرده با uuid: user_uuid ارسال شود.
  2. کاربر ثبت‌نام‌کرده با موبایل: mobile ارسال شود (کاربر موجود پیدا می‌شود).
  3. بیمار جدید بدون ثبت‌نام: 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",
      "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 | pending
  • services: array of service items to attach; price_rials snapshot از 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:

  1. Creates a PatientRecord for the appointment's user (if not already existing) under the doctor entity
  2. Creates a blank PatientSession linked to the appointment
  3. اگر نوبت با آدرس کلینیک ثبت شده باشد (appointment.address_idDoctorAddress.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.