Files
clinicpro/docs/api/patient.md
T
hamedandClaude Opus 5 1f58b1b9b3 feat(insurance): bill an appointment with a chosen service kind and insurance
An appointment can now carry the insurance it is billed with: the service kind
(outpatient/inpatient) and the basic insurance. Confirming it no longer hands the
whole amount to the patient — the visit is split through BillingCalculator with the
coverage percent of that service kind, and the choice travels to the encounter and
the invoice built from it.

The enabled service kinds are a tenant-wide setting (all of that tenant's
insurances share it), so a tenant covering only one kind is never asked which one:
the panel resolves it the same way the server does.

- add tenant_service_category_settings + TenantServiceCategoryService, exposed on
  the existing insurance-pricing endpoint (service_categories,
  default_service_category); at least one kind must stay enabled
- add appointments.insurance_service_category / insurance_base_id with
  AppointmentInsuranceService validating them against the tenant's own settings
  and active contracts (basic only), accepted by PATCH and by confirm
- snapshot the kind on patient_sessions and invoices; the visit's coverage rule is
  resolved per kind (services keep using their own ServiceItem.service_category)
- lib/insuranceShares becomes the single client-side mirror of BillingCalculator,
  shared by the confirm modal, the appointment edit page and the session form
- surface the selection: confirm modal (with live shares), turns timeline chip,
  appointment edit page, patient record service card and invoice summary
- the session form shows the insurance block whenever the tenant has an active
  contract and prefills the patient's own insurance, so it can be changed
- fix: the confirm modal showed a zero visit price when the appointment had none —
  it now falls back to the tenant's free-visit price like the server
- fix: useServiceCategories read one level too shallow, so Persian labels never
  arrived and raw enum keys leaked into the contract summary
- fix: BlogsPage test asserted the public blogs endpoint after the page moved to
  the admin one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:50:14 +03:30

923 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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](#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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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](insurance.md) برای tenant فعال باشد، مقدار `> 0` **الزامی** است.
- `payment_method`: `cash` | `card` | `insurance` | `online` | `pending`
- `session_at` (اختیاری): زمان پذیرش (unix)؛ اگر نیاید `null` می‌ماند و زمان ثبت (`created_at`) مبنا است.
- `inventory_package_uuid` (اختیاری): مرجع پکیج مصرفی ([inventory](inventory.md))؛ فقط پکیج متعلق به همان tenant پذیرفته می‌شود، وگرنه بی‌صدا نادیده گرفته می‌شود. روی قیمت اثری ندارد (فقط مرجع).
- `consumables` (اختیاری): کالاهای مصرفی از انبار ([inventory](inventory.md)). `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`](clinic-services.md#get-apiv1service-categories). درصد پوششِ **ویزیت** از همین نوع resolve می‌شود؛ سهم هر خدمت همچنان از `service_category` خودِ خدمت می‌آید. `null` → سرپایی. در پاسخ به‌همراه `insurance_service_category_label` برمی‌گردد و در `PATCH /api/v1/session/{uuid}` هم پذیرفته می‌شود.
- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد مؤثر قرارداد فعال (زنجیرهٔ resolve — [insurance.md](insurance.md#قاعدهٔ-درصد-پوشش-coverage-percent-model)، با نوع خدمتِ `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](insurance.md)).
- **سقف:** `annual_ceiling_rials` با وجود نامش به‌صورت **سقف هر قلم** اعمال می‌شود؛ انباشت سالانه‌ای در کد وجود ندارد.
- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیش‌نمایش پنل هم همین قاعده را سمت کلاینت آینه می‌کند.
**اتصال خودکار مطالبه‌ی بیمه:** اگر session دارای `insurance_base_id` یا `insurance_supplementary_id` باشد، پس از ثبت به‌صورت خودکار صورتحساب ساخته و نهایی می‌شود و مطالبه(های) بیمه در وضعیت `pending` ایجاد می‌گردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحه‌ی [مطالبات بیمه](billing.md) قابل پیگیری و ارسال‌اند. خطا در این مرحله ثبت session را خراب نمی‌کند (لاگ می‌شود). برای هر صورتحساب فقط یک‌بار مطالبه ساخته می‌شود.
**Response 201:**
```json
{
"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):**
```json
{
"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:**
```json
{
"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:**
```json
{
"method": "wallet | pos | cash | card",
"amount_rials": 200000,
"paid_at": 1770000000
}
```
- `method: "wallet"` همان مبلغ را از کیف پول بیمار کسر می‌کند (تراکنش debit با `reference: "session:{uuid}"`).
- `paid_at` اختیاری است (پیش‌فرض: اکنون).
**Response 201:** session object با فیلدهای صورتحساب:
```json
{
"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
```
تاریخچه‌ی کامل تغییرات مالی/خدماتی مراجعه (جدید → قدیم). هر رکورد:
```json
{
"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`
```json
{ "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`
```json
{ "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`
```json
{ "body": "متن یادداشت", "pinned": false }
```
`body` الزامی (trim)؛ `pinned` اختیاری (پیش‌فرض `false`). `author`/سازنده سمت سرور از کاربر جاری (`real_name` یا موبایل) پر می‌شود. Response `201`.
### PATCH `/api/v1/patient/note/{uuid}`
```json
{ "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` ثبت می‌شود.
```json
{ "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` با ثبتِ کاربرِ عامل و روش پرداخت می‌سازد.
```json
{ "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`
```json
{ "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` | رکورد/تماس یافت نشد یا مالک دیگر |