# 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:` | every record of the clinic | | Doctor, active context = a clinic they belong to | `clinic:` | only records of **their own** patients in that clinic | | Doctor, otherwise | `doctor:` | their personal-office records only | | Secretary, active context = clinic | `clinic:` | records of the doctors assigned to that secretary | | Secretary, active context = doctor | `doctor:` | 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 | --- ### الگوی شمارهٔ پرونده (2026-08) شمارهٔ پرونده می‌تواند به‌جای ورود دستی، از یک الگوی per-tenant تولید شود. الگو و شمارنده‌اش روی `record_number_patterns` می‌نشینند — یک ردیف به ازای هر محیط. #### `GET /api/v1/patient-record-number-settings` **دسترسی:** `patients.view` (منشی/پزشکِ عضو با همان مجوز). محیط از `UserActiveContext` حل می‌شود؛ محیطِ حل‌نشده → `403 ERR_FORBIDDEN_001`. خروجی واقعی (محیطی که هنوز چیزی ذخیره نکرده — همه‌چیز پیش‌فرض): ```json {"success":true,"data":{"enabled":false,"pattern":"{YY}-{SEQ:4}","reset_policy":"none","counter":0,"next_preview":"05-0001","can_edit":true}} ``` | فیلد | معنی | |---|---| | `enabled` | تولید خودکار روشن است یا نه. خاموش = رفتار قدیمی (ورود دستی برای همه) | | `pattern` | الگو با توکن‌های `{YYYY}` `{YY}` `{MM}` `{SEQ}` `{SEQ:n}` | | `reset_policy` | `none` \| `yearly` \| `monthly` — بر مبنای تقویم **شمسی** | | `counter` | شمارندهٔ فعلیِ همین دوره | | `next_preview` | شمارهٔ بعدی، بدون مصرف‌کردن شمارنده | | `can_edit` | آیا همین کاربر اجازهٔ `PUT` دارد (صاحب محیط یا ادمین) | #### `PUT /api/v1/patient-record-number-settings` **دسترسی:** فقط **صاحب محیط** (مالک کلینیک، یا پزشک در مطب شخصی خودش) و `ROLE_ADMIN`. پزشکِ مهمانِ کلینیک و منشی — حتی با `patients.update` — رد می‌شوند: شمارهٔ پرونده قرارداد ثبتِ کل مجموعه است. | فیلد | نوع | الزامی | قاعده | |---|---|---|---| | `enabled` | bool | — | پیش‌فرض `false` | | `pattern` | string | ✅ | حداکثر ۶۰ نویسه، فقط توکن‌های مجاز، حتماً شامل `{SEQ...}` | | `reset_policy` | string | — | `none` \| `yearly` \| `monthly`، پیش‌فرض `none` | خروجی واقعی: ```json {"success":true,"data":{"enabled":true,"pattern":"MD-{YY}-{SEQ:4}","reset_policy":"yearly","counter":0,"next_preview":"MD-05-0001","can_edit":true}} ``` **Errors** (پیام‌ها واقعی‌اند — از اجرای همین اندپوینت): | Code | HTTP | field | شرط | |------|------|-------|-----| | `ERR_FORBIDDEN_001` | 403 | — | «تغییر الگوی شماره پرونده فقط با حساب صاحب مجموعه ممکن است» | | `ERR_VALIDATION_002` | 422 | `pattern` | «توکن ناشناخته: {FOO} — مجاز: {YYYY}، {YY}، {MM}، {SEQ} یا {SEQ:n}» | | `ERR_VALIDATION_002` | 422 | `pattern` | «الگو باید {SEQ} یا {SEQ:n} داشته باشد، وگرنه همهٔ پرونده‌ها یک شماره می‌گیرند» | | `ERR_VALIDATION_002` | 422 | `pattern` | «وقتی سال در الگو هست، ریست شمارنده باید سالانه یا ماهانه باشد» | | `ERR_VALIDATION_002` | 422 | `pattern` | «وقتی {MM} در الگو هست، ریست شمارنده باید ماهانه باشد» | | `ERR_VALIDATION_002` | 422 | `reset_policy` | «سیاست ریست شمارنده نامعتبر است» | | `ERR_VALIDATION_001` | 422 | — | بدنهٔ غیر-JSON | - **تغییر الگو شمارنده را صفر نمی‌کند.** شماره‌های صادرشده وجود دارند و شروع دوبارهٔ دنباله مستقیم به `uniq_patient_record_number` می‌خورد. صفرشدن فقط با ورود به دورهٔ تازه (سال/ماه شمسی) رخ می‌دهد. - **الگوی خاموش هم اعتبارسنجی می‌شود** تا خطای الگو سرِ فرمِ تنظیمات دیده شود، نه روزی که کاربر روشنش می‌کند و ساختِ پرونده می‌شکند. --- ### 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}]` را برمی‌گرداند. - **شمارهٔ پرونده با الگوی فعال (2026-08):** اگر محیط الگوی فعال داشته باشد و `record_number` فرستاده **نشود**، سرور شمارهٔ بعدیِ همان الگو را می‌سازد و در پاسخ برمی‌گرداند (مثلاً `"record_number": "MD-0001"`). فرستادنِ `record_number` توسط کسی جز صاحب مجموعه → `403 ERR_FORBIDDEN_001` با `field: record_number` و پیام «ثبت دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته می‌شود». بدون الگوی فعال، رفتار قبلی برقرار است: شماره دستی است و همه می‌توانند بفرستند. همین قاعده روی `PATCH /patient/{uuid}` هم اعمال می‌شود. تنظیم الگو: [الگوی شمارهٔ پرونده](#الگوی-شمارهٔ-پرونده-2026-08). - **پروندهٔ خودکارِ نوبت هم شماره می‌گیرد:** پرونده‌ای که هنگام قطعی‌شدن نوبت ساخته می‌شود (`PatientService::autoCreateOnAppointmentConfirm`) از همان دنباله شماره می‌گیرد؛ پیش از این همیشه `null` بود. - برچسب متعلق به 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 می‌شوند. > **اثر جانبیِ عمدی (2026-08):** اگر این پرونده `record_number = null` باشد و محیط الگوی > فعال داشته باشد، همین `GET` شماره را تخصیص می‌دهد و ذخیره می‌کند — یعنی پرونده‌های > ساخته‌شده پیش از الگو، اولین بار که باز می‌شوند شماره‌دار می‌شوند. فراخوانی دوم چیزی > نمی‌نویسد و همان شماره را برمی‌گرداند. بدون الگوی فعال، این `GET` فقط می‌خواند. **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` (مالک رکورد) > **موجودی سراسری، دفتر per-محیط.** کیف پول مالِ خودِ بیمار است، پس `balance_rials` > مجموع credit − debit در **همهٔ** محیط‌هاست؛ اگر per-محیط می‌شد، بیمار در هر مطب > بخشی از پول خودش را می‌دید. > > ولی سطرهای دفتر (`recent_transactions` و `wallet/transactions`) فقط تراکنش‌هایی > را برمی‌گردانند که در **همین محیط** ثبت شده‌اند: بدون این تفکیک، کلینیک A می‌خواند > بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده (`created_by_name`). > > در نتیجه **جمعِ سطرهای نمایش‌داده‌شده لزوماً با `balance_rials` برابر نیست** — > این تعمدی است و باید در UI هم گفته شود. > > تراکنش‌هایی که محیط ثبتشان معلوم نیست (پیش از این تفکیک، یا بیرون از هر محیط مثل > سهم نماینده) در **همهٔ** محیط‌ها دیده می‌شوند؛ پنهان‌کردنشان تاریخچهٔ موجودِ یک > بیمار را ناپدید می‌کرد. جزئیات: [architecture/tenancy.md](../architecture/tenancy.md). ### GET `/api/v1/patient/{uuid}/payments` لیست پرداخت‌های درگاهیِ بیمار (paginated). Query: `page`, `limit` (≤100)، `status` (اختیاری: `pending|success|failed|canceled|refunded`). Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at }], meta: { totalRecords, totalPages, currentPage } }` هر تراکنش برای شفافیت این فیلدها را دارد: `type` (credit/debit)، `payment_method` (card/pos/cash/gateway/wallet یا null)، `description` (دلیل)، `reference` (مرجعِ ماشینی مثل `session:{uuid}`)، `created_by_name` (کاربرِ ثبت‌کننده)، `status` (`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). `meta.totalRecords` هم فقط همین محیط را می‌شمارد، نه کل تاریخچهٔ بیمار را. Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }` ### Errors | HTTP | Code | Description | |------|------|-------------| | 404 | `ERR_PATIENT_001` | رکورد یافت نشد یا متعلق به مالک دیگر | | 422 | `ERR_VALIDATION_001` | مبلغ شارژ/برداشت ≤ 0 | | 422 | `ERR_WALLET_INSUFFICIENT` | مبلغ برداشت از موجودی کیف‌پول بیشتر است | --- ## کال سنتر بیمار (Call Center) لاگ تماس‌های تلفنی با بیمار (تب «کال سنتر»). scope به رکورد و مالک. **Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد) ### GET `/api/v1/patient/{uuid}/calls` لیست (جدیدترین بر اساس `called_at`). Query: `outcome` (اختیاری: `success|missed`). Response: `{ success, data: [{ uuid, subject, summary, outcome, called_at, personnel, created_at }] }` ### POST `/api/v1/patient/{uuid}/call` ```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` | رکورد/تماس یافت نشد یا مالک دیگر |