# 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 (10–50) | | `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`. --- ## ضمیمه‌های بیمار (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 دیگر |