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

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

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

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

486 lines
20 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.
**Base path:** `/api/v1`
**Auth:** Bearer JWT (doctor, clinic, or secretary with `appointments.view` permission required)
---
## Endpoints
### List Patients
```
GET /api/v1/patients
```
Returns a paginated list of patient records belonging to the authenticated entity.
**Query params:**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `page` | int | 1 | Page number |
| `limit` | int | 20 | Items per page (1050) |
| `search` | string | — | Search by patient name or phone |
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "علی رضایی", "phone": "09123456789" },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
],
"meta": {
"totalRecords": 42,
"totalPages": 3,
"currentPage": 1
}
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No active plan with `patient_records` feature |
---
### Create Patient Record
```
POST /api/v1/patient
```
Creates a patient record for a user under the current entity. If the record already exists, returns the existing record (idempotent).
سه حالت پشتیبانی می‌شود:
1. **کاربر ثبت‌نام‌کرده با uuid:** `user_uuid` ارسال شود.
2. **کاربر ثبت‌نام‌کرده با موبایل:** `mobile` ارسال شود (کاربر موجود پیدا می‌شود).
3. **بیمار جدید بدون ثبت‌نام:** `mobile` + `name` ارسال شود؛ اگر کاربری با آن موبایل نباشد، `User` جدید (نقش `ROLE_USER`، بدون رمز عبور) ساخته می‌شود سپس پرونده.
**Request body:**
```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` (same as list)
**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,
"final_price_rials": 230000,
"payment_method": "cash",
"is_paid": true,
"services": [
{
"uuid": "...",
"service_item_uuid": "...",
"service_name": "کندلا ۲۰۲۱",
"staff_uuid": "...",
"staff_name": "ژیلا فتحی",
"price_rials": 50000,
"quantity": 1,
"line_total_rials": 50000,
"created_at": 1718375000
}
],
"invoice_uuid": "...",
"invoice_status": "finalized",
"patient_debt_rials": 0,
"notes": "...",
"created_at": 1718375000,
"updated_at": 1718375000
}
],
"meta": { "totalRecords": 8, "totalPages": 1, "currentPage": 1 }
}
```
**فیلدهای غنی‌سازی‌شده (برای تب «سرویس‌ها»):**
| Field | Type | Notes |
|-------|------|-------|
| `is_paid` | bool | `true` وقتی `payment_method !== "pending"` |
| `services` | array | سرویس‌های ثبت‌شده در این session (نام، انجام‌دهنده/`staff`, قیمت، تعداد) |
| `invoice_uuid` | string\|null | uuid فاکتور مرتبط (اگر ساخته شده باشد؛ برای «مشاهده فاکتور») |
| `invoice_status` | string\|null | `draft`\|`finalized`\|`paid`\|`void` |
| `patient_debt_rials` | int | مانده بدهی سهم بیمار؛ `0` اگر تسویه شده، وگرنه سهم بیمارِ فاکتور یا `final_price_rials` |
> **ثبت پرداخت («تکمیل پرداخت»):** از همان `PATCH /api/v1/session/{uuid}` با بدنه‌ی `{ "payment_method": "cash" }` استفاده می‌شود؛ پس از آن `is_paid=true` و `patient_debt_rials=0` می‌شود. مشاهده‌ی فاکتور از `GET /api/v1/billing/invoices/{invoice_uuid}` (این endpoint اکنون برای منشیِ فعال هم در دسترس است).
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned by caller |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
### List Patient Appointments
```
GET /api/v1/patient/{uuid}/appointments
```
نوبت‌های همین بیمار را برمی‌گرداند. برای جلوگیری از نشتِ اطلاعات بین ارائه‌دهنده‌ها، فقط نوبت‌هایی نمایش داده می‌شوند که با پزشک(های) خودِ صاحب پرونده گرفته شده‌اند:
- ارائه‌دهنده‌ی **پزشک**: نوبت‌های بیمار با همان پزشک.
- ارائه‌دهنده‌ی **کلینیک** (و منشیِ فعالِ کلینیک): نوبت‌های بیمار با پزشکانی که دعوت پذیرفته‌شده (`accepted`) در آن کلینیک دارند.
مرتب‌شده بر اساس `starts_at` نزولی. خروجی آرایه‌ی ساده است (بدون صفحه‌بندی).
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "…",
"starts_at": 1754000000,
"ends_at": 1754001800,
"status": "confirmed",
"doctor_name": "دکتر ژیلا فتحی",
"service_name": null,
"price_rials": null,
"created_at": 1754000000
}
]
}
```
`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد).
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned by caller |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
### Create Session
```
POST /api/v1/patient/{uuid}/session
```
Creates a new visit session for a patient record.
**Request body:**
```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": "...",
"services": [
{
"service_item_uuid": "...",
"staff_uuid": null,
"quantity": 2
}
]
}
```
**Field notes:**
- `payment_method`: `cash` | `card` | `insurance` | `online` | `pending`
- `services`: array of service items to attach; `price_rials` snapshot از ServiceItem؛ `quantity` (پیش‌فرض ۱) → `line_total_rials = price_rials × quantity`. هر `SessionService` در پاسخ `quantity` و `line_total_rials` دارد.
- `final_price_rials` (سهم بیمار) به این صورت محاسبه می‌شود:
- **ویزیت:** `round(visit_price × (1 - base%) × (1 - supp%))` با درصدهای انتخاب‌شده در فرم.
- **هر خدمت:** سهم بیمار با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه می‌شود؛ یعنی فقط خدمتی که بیمه‌ی انتخاب‌شده آن را پوشش می‌دهد تخفیف می‌گیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث می‌برد). خدمتِ بدون پوشش، کامل بر عهده‌ی بیمار است.
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه).
- این محاسبه دقیقاً همان منطقِ صورتحساب/مطالبات است؛ پیش‌نمایش پنل هم همین قاعده را سمت کلاینت آینه می‌کند.
**اتصال خودکار مطالبه‌ی بیمه:** اگر session دارای `insurance_base_id` یا `insurance_supplementary_id` باشد، پس از ثبت به‌صورت خودکار صورتحساب ساخته و نهایی می‌شود و مطالبه(های) بیمه در وضعیت `pending` ایجاد می‌گردد (پایه/مکمل، فقط برای سهم بیمه > ۰). این مطالبات در صفحه‌ی [مطالبات بیمه](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 |
---
### Update Session
```
PATCH /api/v1/session/{uuid}
```
Updates mutable fields on a session.
**Request body (all optional):**
```json
{
"notes": "...",
"payment_method": "card"
}
```
**Response 200:**
```json
{
"success": true,
"data": { ...session object... }
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_SESSION_NOT_FOUND` | 404 | Session not found or not owned |
---
## Auto-Creation on Appointment Confirm
When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointment/{uuid}/status`, the system automatically:
1. Creates a `PatientRecord` for the appointment's user (if not already existing) under the doctor entity
2. Creates a blank `PatientSession` linked to the appointment
3. **اگر نوبت با آدرس کلینیک ثبت شده باشد** (`appointment.address_id``DoctorAddress.clinic_id`)، همان دو مرحله برای آن **کلینیک** (`entity_type='clinic'`) هم تکرار می‌شود. اگر آدرس نوبت کلینیک نداشت ولی دکتر فقط عضو **یک** کلینیک بود، به همان کلینیک اضافه می‌شود.
هر شاخه (doctor / clinic) مستقل و فقط در صورت فعال‌بودن ویژگی `patient_records` برای همان entity اجرا می‌شود. duplicate با `findByEntityAndUser` جلوگیری می‌شود.
**انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است.
**آدرس نوبت:** هنگام رزرو، `address_id` خودکار از `location_id` همان session برنامه‌ی هفتگی ست می‌شود (در همه‌ی مسیرهای رزرو). ثبت `location_id` برای هر شیفت فعال در برنامه‌ی هفتگی الزامی است (`POST/PATCH /api/v1/appointment-settings/weekly-schedule`)؛ در غیر این صورت `422`.