- Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly. - Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations. - Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings. - Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges. - Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts. - Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers. - Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability.
914 lines
48 KiB
Markdown
914 lines
48 KiB
Markdown
# Patient Records & Sessions API
|
||
|
||
## Overview
|
||
|
||
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the `patient_records` feature.
|
||
|
||
**Base path:** `/api/v1`
|
||
**Auth:** Bearer JWT (doctor, clinic, or secretary)
|
||
|
||
---
|
||
|
||
## Record access model
|
||
|
||
Every endpoint in this file resolves the caller's environment through
|
||
`App\Patient\Security\PatientRecordScopeResolver`. The **active context**
|
||
(`UserActiveContext`, set by `POST /api/v1/auth/switch-context`) decides it — not the role
|
||
alone, because a doctor invited into a clinic has records in both places.
|
||
|
||
| Caller | Scope | Visible records |
|
||
|---|---|---|
|
||
| Clinic owner | `clinic:<id>` | every record of the clinic |
|
||
| Doctor, active context = a clinic they belong to | `clinic:<id>` | only records of **their own** patients in that clinic |
|
||
| Doctor, otherwise | `doctor:<id>` | their personal-office records only |
|
||
| Secretary, active context = clinic | `clinic:<id>` | records of the doctors assigned to that secretary |
|
||
| Secretary, active context = doctor | `doctor:<id>` | that doctor's records |
|
||
|
||
**"Their own patients" is derived, not stored.** A clinic record is per-patient
|
||
(`UNIQUE(entity_type, entity_id, user_id)`) and deliberately shared between the clinic's
|
||
doctors — there is no doctor column on it and none should be added. A record counts as a
|
||
member doctor's when the patient has at least one appointment with that doctor **in that
|
||
clinic**. Manually created visits carry no doctor (`PatientSession` has no creator column),
|
||
so they never widen a member doctor's view on their own.
|
||
|
||
The member-doctor path additionally requires `ClinicDoctorPermission.patients.view`, and a
|
||
clinic secretary requires an active `DoctorSecretary` row. Both refuse when `active = false`,
|
||
so **deactivating a doctor or secretary is the single mechanism that ends their access** —
|
||
the clinic owner keeps everything, and no record is moved or deleted. A doctor whose clinic
|
||
membership was revoked silently falls back to their personal-office scope.
|
||
|
||
> **Read and write use the same rule.** An active member doctor who can see a record can
|
||
> also manage it (notes, sessions, payments, attachments): the clinic record is shared by
|
||
> design, and per-visit ownership is not modelled, so inventing a write-only restriction on
|
||
> top of it would produce confusing 403s. A clinic owner who wants a read-only doctor
|
||
> revokes `patients.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 (10–50) |
|
||
| `search` | string | — | Search by patient name, phone or national code |
|
||
| `tags` | string | — | Comma-separated tenant-tag uuids; matches records having any of them |
|
||
| `gender` | string | — | Patient `UserProfile.gender` (e.g. `male`/`female`) |
|
||
| `insurance_id` | int | — | Patient's basic insurance id (`UserProfile.basic_insurance_id`) |
|
||
| `admitted_from` / `admitted_to` | int | — | Record creation (تاریخ پذیرش) unix-seconds range |
|
||
| `service_status` | string | — | `pending` (has an unpaid session) or `completed` (has sessions, none unpaid) |
|
||
| `has_debt` | bool | — | `1` → only records with an unpaid session (`payment_method='pending'`) |
|
||
|
||
> «بدهی» و «وضعیت سرویس» بر پایهی وجود مراجعهی پرداختنشده تعریف شدهاند (مدل بدهی مستقل ندارد). فیلترها روی هم AND میشوند و در count هم اعمال میگردند.
|
||
|
||
**Response 200:**
|
||
|
||
```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` دارد.
|
||
- `base_insurance_discount_percent` / `supplementary_discount_percent`: **ورودی محاسبه نیستند.** هر مقداری که ارسال شود نادیده گرفته و از درصد قرارداد فعال (`TenantInsurance.coveragePercent`) بازنویسی میشود؛ صرفاً snapshot برای نمایش/گزارشاند.
|
||
- `final_price_rials` (سهم بیمار) به این صورت محاسبه میشود:
|
||
- **ویزیت:** با قاعدهی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` — همان مسیری که `InvoiceService` برای صدور فاکتور میرود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب میشد و با فاکتور واگرا میشد.)
|
||
- **هر خدمت:** سهم بیمار با قاعدهی پوشش همان بیمهگر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه میشود؛ یعنی فقط خدمتی که بیمهی انتخابشده آن را پوشش میدهد تخفیف میگیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث میبرد). خدمتِ بدون پوشش، کامل بر عهدهی بیمار است.
|
||
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت) + Σ(کالاهای مصرفی)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه). کالاهای مصرفی در `consumables_total_rials` جدا گزارش میشوند.
|
||
- **گیت پوشش:** اگر `ServiceItem.insurance_covered` غیرفعال باشد یا برای tenant قرارداد فعالی نباشد، هیچ پوششی اعمال نمیشود و کل مبلغ سهم بیمار است. این پرچم دستی ست نمیشود؛ از ردیفهای `TenantServiceCoverage` سینک میشود ([insurance.md](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, "paid_at": 1770000000, "created_by_name": "...", "created_at": 1770000000 }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**Errors:**
|
||
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_SESSION_NOT_FOUND` | 404 | Session not found or not owned |
|
||
| `ERR_SESSION_PAYMENT_INVALID` | 422 | روش نامعتبر یا مبلغ ≤ ۰ |
|
||
| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | مبلغ از مانده بدهی بیشتر است |
|
||
| `ERR_WALLET_INSUFFICIENT` | 422 | موجودی کیف پول کافی نیست (روش wallet) |
|
||
|
||
**Session list debt:** در `GET /api/v1/patient/{uuid}/sessions`، فیلد `patient_debt_rials` = سهم بیمار (از فاکتور در صورت وجود) منهای `discount_rials` و `paid_total_rials`.
|
||
|
||
---
|
||
|
||
### Edit Session Payment
|
||
|
||
```
|
||
PATCH /api/v1/session/{uuid}/payments/{paymentUuid}
|
||
DELETE /api/v1/session/{uuid}/payments/{paymentUuid}
|
||
```
|
||
|
||
ویرایش/حذف یک پرداخت ثبتشده. پس از تغییر، فیلدهای کششدهی تسویه (`payment_method`، `paid_at`، `is_paid`) و `paid_total_rials`/`patient_debt_rials` بازمحاسبه میشوند. هر عملیات در **Audit Log** ثبت میشود.
|
||
|
||
**PATCH body (همه اختیاری):** `{ "method": "pos|cash|card", "amount_rials": 300000, "paid_at": 1770000000 }`
|
||
|
||
- **پرداخت `wallet` قابل ویرایش/حذف نیست** (`422` — جبران تراکنش کیف پول پشتیبانی نمیشود).
|
||
- مجموع پرداختها پس از ویرایش نباید از «مبلغ نهایی منهای تخفیف» بیشتر شود.
|
||
|
||
**Response 200:** session object با فیلدهای صورتحساب (مثل بالا).
|
||
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_SESSION_NOT_FOUND` | 404 | Session یافت نشد یا متعلق به owner نیست |
|
||
| `ERR_SESSION_PAYMENT_INVALID` | 404/422 | پرداخت یافت نشد / روش نامعتبر / پرداخت wallet |
|
||
| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | مبلغ از مانده بیشتر است |
|
||
|
||
### Session Audit Log
|
||
|
||
```
|
||
GET /api/v1/session/{uuid}/audit-log
|
||
```
|
||
|
||
تاریخچهی کامل تغییرات مالی/خدماتی مراجعه (جدید → قدیم). هر رکورد:
|
||
|
||
```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 میشوند)
|
||
|
||
### محیط پرونده — یکی، نه هر دو (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` | رکورد/تماس یافت نشد یا مالک دیگر |
|