Files
clinicpro/docs/api/patient.md
T
hamedandClaude Opus 5 6ab1eb6483 fix(tenant): scope the patient wallet ledger to the environment reading it
ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.

The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.

The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.

Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).

Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.

Removing the fix turns 3 of the 6 new tests red.

Tests: 902 backend (+6), 570 frontend. PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:21:55 +03:30

52 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.

دسترسی منشی: برای ROLE_SECRETARY روی منبع patients اعمال می‌شود (SecretaryAccessChecker). خواندن‌ها از طریق scope() کنترل می‌شوند: منشیِ بدون patients.view هیچ پرونده‌ای نمی‌بیند (scope = unknown → 404/403). نوشتن‌ها guard جداگانه دارند: ایجاد بیمار→patients.create؛ ویرایش/زیرمنابع (note/call/message/medical-record/attachment/session)→patients.update. عملیاتِ مالیِ بیمار (کیف‌پول، پرداختِ جلسه) روی منبع payments اعمال می‌شوند. نبودِ مجوز → 403. جزئیات: secretary.md.

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

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

  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, و 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 | pending
  • session_at (اختیاری): زمان پذیرش (unix)؛ اگر نیاید null می‌ماند و زمان ثبت (created_at) مبنا است.
  • inventory_package_uuid (اختیاری): مرجع پکیج مصرفی (inventory)؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع).
  • consumables (اختیاری): کالاهای مصرفی از انبار (inventory). price_rials snapshot از InventoryItem.price؛ quantity (پیش‌فرض ۱، حداقل ۱). کالاها پوشش بیمه ندارند و مبلغ کاملشان به final_price_rials (سهم بیمار) اضافه می‌شود. آیتم ناموجود یا متعلق به tenant دیگر بی‌صدا رد می‌شود (هم‌رفتار با services). پاسخ شامل consumables[] (با line_total_rials) و consumables_total_rials است.
  • services: array of service items to attach; price_rials snapshot از ServiceItem؛ quantity (پیش‌فرض ۱) → line_total_rials = price_rials × quantity. هر SessionService در پاسخ quantity و line_total_rials دارد.
  • پاسخ‌های مراجعه (لیست، ایجاد، ویرایش) علاوه بر شناسه‌ها، insurance_base_name و insurance_supplementary_name را هم دارند (نام‌ها با کش درون‌درخواستی حل می‌شوند، بدون N+1) تا کارت «سرویس‌ها» در پرونده بیمار بتواند نوع خدمت و بیمه را نشان دهد.
  • insurance_service_category (اختیاری): نوع خدمتِ بیمه‌ایِ این مراجعه — یکی از مقادیر GET /api/v1/service-categories. درصد پوششِ ویزیت از همین نوع resolve می‌شود؛ سهم هر خدمت همچنان از service_category خودِ خدمت می‌آید. null → سرپایی. در پاسخ به‌همراه insurance_service_category_label برمی‌گردد و در PATCH /api/v1/session/{uuid} هم پذیرفته می‌شود.
  • base_insurance_discount_percent / supplementary_discount_percent: ورودی محاسبه نیستند. هر مقداری که ارسال شود نادیده گرفته و از درصد مؤثر قرارداد فعال (زنجیرهٔ resolve — insurance.md، با نوع خدمتِ outpatient برای ویزیت) بازنویسی می‌شود؛ صرفاً snapshot برای نمایش/گزارش‌اند.
  • final_price_rials (سهم بیمار) به این صورت محاسبه می‌شود:
    • ویزیت: خدمتِ سرپایی است و با قاعده‌ی پوشش قرارداد (TenantInsuranceService::coverageRule) از طریق BillingCalculator حساب می‌شود — همان مسیری که InvoiceService برای صدور فاکتور می‌رود. سهم بیمهٔ پایه = round(کل × درصد ÷ 100) و سهم بیمار = کل سهم پایه؛ فرانشیزِ قرارداد پایه بی‌اثر است.
    • هر خدمت: سهم بیمار با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت (TenantServiceCoverage از طریق BillingCalculator) محاسبه می‌شود و درصد از نوع خدمت (ServiceItem.service_category: سرپایی/بستری) گرفته می‌شود؛ یعنی فقط خدمتی که بیمه‌ی انتخاب‌شده آن را پوشش می‌دهد تخفیف می‌گیرد (درصد/سقف، و فرانشیز فقط در قرارداد تکمیلی؛ مقدار نبودِ 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, "payment_method_uuid": null, "reference": null, "paid_at": 1770000000, "created_by_name": "...", "created_at": 1770000000 }
    ]
  }
}

payment_method_uuid (uuid کارت‌خوان/حساب بانکیِ ثبت‌شده) و reference (شناسه تراکنش) از مسیرِ split-paymentِ «قطعی کردن نوبت» (POST /api/v1/appointment/{uuid}/confirm) پر می‌شوند؛ در پرداختِ تک‌روشیِ POST /api/v1/session/{uuid}/payments معمولاً null می‌مانند.

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

هر نوبتی که قطعی می‌شود — از هر مسیری — به‌صورت خودکار:

  1. اگر بیمار در آن محیط پرونده نداشته باشد، یک PatientRecord می‌سازد
  2. یک PatientSession گره‌خورده به همان نوبت می‌سازد (زمان مراجعه = زمان نوبت، هزینه ویزیت و خطوط سرویس از خود نوبت snapshot می‌شوند)
  3. بیمهٔ نوبت (appointments.insurance_base_id + insurance_service_category) روی مراجعه snapshot می‌شود و سهم بیمه/بیمار با BillingCalculator محاسبه می‌گردد. نوع خدمتِ مؤثر: انتخابِ نوبت → تنها نوع فعالِ tenant → سرپایی. نوبتِ بدون بیمه کاملاً سهم بیمار می‌ماند (رفتار قبلی).

محیط پرونده — یکی، نه هر دو (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 (مالک رکورد)

موجودی سراسری، دفتر per-محیط. کیف پول مالِ خودِ بیمار است، پس balance_rials مجموع credit debit در همهٔ محیط‌هاست؛ اگر per-محیط می‌شد، بیمار در هر مطب بخشی از پول خودش را می‌دید.

ولی سطرهای دفتر (recent_transactions و wallet/transactions) فقط تراکنش‌هایی را برمی‌گردانند که در همین محیط ثبت شده‌اند: بدون این تفکیک، کلینیک A می‌خواند بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده (created_by_name).

در نتیجه جمعِ سطرهای نمایش‌داده‌شده لزوماً با balance_rials برابر نیست — این تعمدی است و باید در UI هم گفته شود.

تراکنش‌هایی که محیط ثبتشان معلوم نیست (پیش از این تفکیک، یا بیرون از هر محیط مثل سهم نماینده) در همهٔ محیط‌ها دیده می‌شوند؛ پنهان‌کردنشان تاریخچهٔ موجودِ یک بیمار را ناپدید می‌کرد. جزئیات: architecture/tenancy.md.

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 (confirmedbalance_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). meta.totalRecords هم فقط همین محیط را می‌شمارد، نه کل تاریخچهٔ بیمار را. 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 رکورد/تماس یافت نشد یا مالک دیگر