Merge branch 'dev' into main

# Conflicts:
#	docs/api/doctor.md
This commit is contained in:
hamed
2026-07-19 16:15:30 +03:30
1026 changed files with 190049 additions and 15130 deletions
+36 -1
View File
@@ -31,12 +31,47 @@ Authorization: Bearer <JWT_TOKEN>
{ "success": true, "data": { ... } }
// Paginated
{ "success": true, "data": [...], "meta": { "totalRecords": 100, "totalPages": 5, "currentPage": 1 } }
{ "success": true, "data": [...], "meta": { "totalRecords": 100, "totalPages": 5, "currentPage": 1, "limit": 20 } }
// Error
{ "success": false, "data": null, "errors": [{ "code": "ERR_XXX_000", "message": "..." }] }
```
> `meta.limit` اندازهٔ صفحهٔ **واقعاً اعمال‌شده** است. ریپازیتوری‌ها `limit` درخواستی را به سقف خودشان کاهش می‌دهند (مثلاً لیست پزشکان: سقف ۵۰)، پس برای پیمایش کامل به `meta.totalPages` تکیه کن — نه به این فرض که «تعداد آیتم کمتر از limit درخواستی یعنی صفحهٔ آخر».
---
## Persian digit normalization (global)
Persian (`۰-۹`) and Arabic (`٠-٩`) digits sent in numeric request fields are translated to Latin **server-side, before the controller runs**`src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php`. Every client benefits: the React admin panel, `nobat724_front`, and `clinic-pro-tauri`.
Applies to `POST` / `PUT` / `PATCH` requests under `/api/v1/` with a JSON body, recursively through nested arrays.
**Normalized keys:**
```
mobile, mobile_number, telephone, phone, notification_mobile,
national_code, postal_code,
card_number, account_number, sheba, shaba, iban,
price_rials, amount_rials, amount, free_visit_price_rials,
insurance_price_rials, patient_share_rials, visit_price_rials,
duration_minutes, duration, commission_percent, coverage,
coverage_percent, franchise, ceiling, tax_percent,
base_insurance_discount_percent, supplementary_discount_percent
```
Only **digits** are translated — no characters are stripped, so `IR` in a sheba and `-` in a landline survive. Non-string values (`int`, `bool`, `null`) and keys outside the list are untouched, so a name like `منشی شماره ۲` keeps its Persian digit.
```jsonc
// request
{ "mobile_number": "۰۹۱۲۳۴۵۶۷۸۹", "national_code": "۰۰۱۲۳۴۵۶۷۸", "name": "منشی شماره ۲" }
// what the controller sees
{ "mobile_number": "09123456789", "national_code": "0012345678", "name": "منشی شماره ۲" }
```
> Adding a new numeric field to any endpoint? Add its key to `NUMERIC_KEYS` in the subscriber, otherwise Persian digits reach the database.
---
## Modules
+18 -8
View File
@@ -356,7 +356,7 @@ List all doctors with pagination.
"data": [
{
"uuid": "...",
"title": "دکتر علی احمدی",
"title": "علی احمدی",
"degree": "متخصص",
"gender": "male",
"doctor_rate": 4.5,
@@ -565,7 +565,7 @@ List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`.
"patient_name": "محمد رضایی",
"patient_mobile": "09123456789",
"doctor_uuid": "doctor-uuid",
"doctor_name": "دکتر علی احمدی",
"doctor_name": "علی احمدی",
"slot_start": 1718438400,
"slot_end": 1718439600,
"appointment_date": "2025-06-15",
@@ -598,11 +598,20 @@ Create a new appointment for a patient. If no user exists with the given mobile,
"slot_end": 1718439600,
"patient_mobile": "09123456789",
"patient_name": "علی محمدی",
"patient_national_code": "0012345678",
"service_item_uuids": ["service-uuid-1", "service-uuid-2"],
"duration_from_services": false,
"service_durations": { "service-uuid-1": 75 },
"visit_price_rials": 3000000,
"note": "optional note"
}
```
> `patient_mobile` و `patient_name` هر دو اجباری هستند. اگر کاربری با این شماره موبایل نداشته باشیم، یک کاربر جدید با نقش `ROLE_USER` ساخته می‌شود.
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود.
>
> `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست می‌شوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمی‌گردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ می‌ماند. `service_durations` (`{ "<uuid>": <minutes> }`، فقط با `duration_from_services=true`): override مدتِ هر سرویس برای همان نوبت؛ پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند.
>
> `visit_price_rials` (اختیاری/شرطی): هزینه ویزیت (ریال) که روی نوبت ذخیره می‌شود. اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است.
### Response `201`
```json
@@ -620,7 +629,8 @@ Create a new appointment for a patient. If no user exists with the given mobile,
### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name) |
| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) |
| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) |
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
| `SLOT_TAKEN` | 409 | Slot already booked |
@@ -754,7 +764,7 @@ List all settlement requests.
"data": [
{
"uuid": "...",
"user": { "uuid": "...", "real_name": "دکتر علی احمدی" },
"user": { "uuid": "...", "real_name": "علی احمدی" },
"amount_rials": 1000000,
"status": "pending",
"bank_account": { "bank_name": "بانک ملت", "owner_name": "..." },
@@ -835,7 +845,7 @@ Paginated. Each item:
{
"uuid": "...",
"id": 45,
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"gender": "man",
"medical_code": "12345",
"is_active": true,
@@ -902,7 +912,7 @@ Paginated. Each item:
"status": "confirmed",
"patient_name": "علی رضایی",
"doctor_uuid": "...",
"doctor_name": "دکتر علی احمدی"
"doctor_name": "علی احمدی"
}
```
> `slot_start`/`slot_end` Unix timestamp (ثانیه).
@@ -1150,7 +1160,7 @@ List pre-registration requests. **Permission:** `ROLE_ADMIN`
{
"uuid": "...",
"type": "independent_doctor",
"name": "دکتر احمدی",
"name": "احمدی",
"mobile": "09121234567",
"info": "متخصص داخلی",
"status": "pending",
+115 -17
View File
@@ -1,15 +1,51 @@
# Appointment Settings API
> **Prefix:** `/api/v1/appointment-settings`
> **Permission:** All write endpoints require `AUTH` — must be the doctor owner or `ROLE_ADMIN`
> **Permission:** every endpoint requires `AUTH` and resolves access through one shared rule (below)
Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**.
## Booking context (`clinic_uuid`)
Every endpoint in this file operates inside **one booking context**, selected by the optional
`clinic_uuid` parameter (query string on `GET`/`DELETE`, body field on `POST`/`PATCH`):
| `clinic_uuid` | Context | Services usable | Addresses selectable |
|---|---|---|---|
| omitted / `null` | the doctor's **personal practice** | `entity_type='doctor'` | the doctor's own `personal` addresses |
| a clinic uuid | that **doctor inside that clinic** | `entity_type='clinic'` | that clinic's addresses |
A doctor holds **one schedule per context** — a personal one plus one per clinic — and they are
fully independent: separate sessions, separate `booking_mode` lock, separate date overrides.
Services never cross the boundary (they are polymorphic on `service_sections.entity_type`).
If the doctor is not a member of the given clinic → `422 ERR_VALIDATION_001`
(«این پزشک عضو کلینیک انتخاب‌شده نیست»). Unknown clinic → `404 ERR_VALIDATION_002`.
## Access rule
Given the target doctor and the resolved context, access is granted when the caller is:
1. `ROLE_ADMIN`, **or**
2. the doctor themselves, **or**
3. in a **clinic context only**, someone holding that clinic's `appointment_settings` permission —
`view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md`
*Clinic Doctor Permissions*). The clinic owner always passes this check.
Anything else → `403 ERR_AUTH_006`.
> **Breaking change (2026-07):** a clinic owner can no longer read or write a member doctor's
> **personal** schedule. Without `clinic_uuid` the request targets the personal context, which only
> the doctor and an admin may touch. The clinic panel must send `clinic_uuid`; the admin SPA already
> does (`ScheduleSection` takes a `clinicUuid` prop).
---
## Weekly Schedule
Each doctor has **one** weekly schedule (upsert). The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions` array.
Each doctor has **one weekly schedule per context** (upsert keyed by `doctor_id` + `clinic_id`).
The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions`
array.
### Day Index Convention
@@ -29,14 +65,19 @@ Each doctor has **one** weekly schedule (upsert). The schedule is keyed by **day
Create or update the weekly schedule for a doctor (upsert).
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود.
>
> **الزام محیط:** آدرس انتخاب‌شده باید به همان context تعلق داشته باشد. آدرس کلینیک در محیط شخصی (و برعکس) → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»).
>
> **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند.
### Request Body (`application/json`)
```json
{
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"clinic_uuid": null,
"schedule": {
"0": {
"sessions": [
@@ -88,7 +129,9 @@ Create or update the weekly schedule for a doctor (upsert).
"meta": {
"online_booking_enabled": true,
"booking_window_value": 2,
"booking_window_unit": "month"
"booking_window_unit": "month",
"booking_mode": "service",
"buffer_minutes": 5
}
}
```
@@ -98,8 +141,14 @@ Create or update the weekly schedule for a doctor (upsert).
| `online_booking_enabled` | boolean | ❌ | `false` = no online booking; the public slot/month endpoints return no availability |
| `booking_window_value` | integer | ❌ | How far ahead patients may book (≥ 1) |
| `booking_window_unit` | string | ❌ | `"week"` or `"month"` |
| `booking_mode` | string | ❌ | `"slot"` (پیش‌فرض) = نوبت‌دهی اسلاتی با مدت ثابت (`duration_per_patient`). `"service"` = مدت هر نوبت از `duration_minutes` سرویسِ انتخاب‌شده؛ زمان‌ها با `GET /api/v1/appointment-service-slots` گرفته می‌شوند. مقدار نامعتبر نادیده گرفته می‌شود |
| `buffer_minutes` | integer | ❌ | فقط حالت سرویسی: فاصلهٔ بین نوبت‌ها (دقیقه، ≥ 0). در `slot_end` ذخیره نمی‌شود؛ فقط فاصلهٔ بین زمان‌های پیشنهادی |
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month" }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
> Defaults when `meta` is absent: `{ online_booking_enabled: true, booking_window_value: 1, booking_window_unit: "month", booking_mode: "slot", buffer_minutes: 0 }`. `meta` is stored inside the schedule `setting` JSON (no DB migration) and is **preserved** when only `schedule` is sent. `SlotCalculatorService` rejects any date in the past, beyond `today + value unit`, or when online booking is disabled — for the weekly schedule, date overrides, and `appointment-slots` alike.
>
> **اجبار حالت سرویسی:** اگر `booking_mode = service` ذخیره شود ولی پزشک هیچ سرویسِ «نمایش در نوبت‌دهی» (`bookable = true`) نداشته باشد، `POST`/`PATCH` برنامهٔ هفتگی با `422` (`ERR_VALIDATION_001`, field `booking_mode`) رد می‌شود.
>
> **غیرقابل‌تغییر پس از ثبت:** `booking_mode` فقط تا **اولین ثبت** قابل‌انتخاب است. پس از آنکه یک‌بار به‌صورت صریح ذخیره شد (در `setting.meta.booking_mode` نوشته شد)، هر `POST`/`PATCH` که آن را تغییر دهد با `422` («نوع نوبت‌دهی پس از ثبت قابل تغییر نیست»، field `booking_mode`) رد می‌شود. پاسخِ `toArray` فیلد boolean `booking_mode_locked` را برمی‌گرداند (`true` = قفل‌شده) تا پنل توگل را غیرفعال کند. رکوردهای قدیمی که هنوز mode صریح ندارند، `booking_mode_locked=false` و یک‌بار قابل‌انتخاب‌اند.
**Session Config Object:**
@@ -137,8 +186,11 @@ Create or update the weekly schedule for a doctor (upsert).
"meta": {
"online_booking_enabled": true,
"booking_window_value": 1,
"booking_window_unit": "month"
"booking_window_unit": "month",
"booking_mode": "slot",
"buffer_minutes": 0
},
"booking_mode_locked": true,
"created_at": 1717000000,
"updated_at": 1717000000
}
@@ -179,7 +231,7 @@ Same structure as POST response.
Update weekly schedule. `{uuid}` can be schedule UUID or doctor UUID.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body
```json
@@ -222,7 +274,7 @@ Updated schedule object (same structure as POST).
Delete a weekly schedule.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
> Note: route is `/booking-setting/`, not `/appointment-settings/`
@@ -248,7 +300,7 @@ Override a specific date — mark it inactive (day off) or give it custom sessio
Get all date overrides for a doctor.
**Permission:** `AUTH`must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
**Permission:** `AUTH`see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
@@ -278,7 +330,7 @@ Get all date overrides for a doctor.
Create a date override.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body
```json
@@ -369,7 +421,7 @@ Override object (same structure as above).
Update a date override.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body (all optional)
```json
@@ -408,7 +460,7 @@ Updated override object.
Delete a date override.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Response `200`
```json
@@ -425,7 +477,7 @@ Mark a date range as holiday — all slots blocked, no overrides apply.
Get all holidays for a doctor.
**Permission:** `AUTH`must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
**Permission:** `AUTH`see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
@@ -455,7 +507,7 @@ Get all holidays for a doctor.
Create a holiday range.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body
```json
@@ -514,7 +566,7 @@ Get a single holiday.
Update a holiday.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Request Body (all optional)
```json
@@ -535,7 +587,7 @@ Updated holiday object.
Delete a holiday.
**Permission:** `AUTH`must be the doctor owner or `ROLE_ADMIN`
**Permission:** `AUTH`see [Access rule](#access-rule)
### Response `200`
```json
@@ -573,7 +625,7 @@ The `SlotCalculatorService` calculates available slots in this priority order:
### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
**Permission:** `AUTH`must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
**Permission:** `AUTH`see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise).
Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to.
@@ -624,3 +676,49 @@ Returns all locations a doctor can assign as `location_id` in their schedule ses
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
---
## Context additions (2026-07)
### Response fields
`WeeklySchedule.toArray()` now also returns:
| Field | Type | Meaning |
|---|---|---|
| `clinic_uuid` | `string\|null` | the clinic this schedule belongs to; `null` = personal practice |
| `context` | `"personal" \| "clinic"` | convenience mirror of the above |
`DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid`
plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below).
### Holidays are global by default
A holiday means "the doctor is not there", which is a physical fact — so unlike schedules and date
overrides it is **not** per-context by default:
| `clinic_id` | Meaning |
|---|---|
| `NULL` | the doctor is absent **everywhere** — applies to the personal practice and every clinic |
| set | the doctor is absent in that clinic only |
`GET /holidays/list/{doctorUuid}?clinic_uuid=…` returns the **union**: the clinic's own holidays plus
the doctor's global ones. Global rows come back with `editable: false` — a clinic must see that the
doctor is away but may not delete that fact.
`POST /holidays` **without** `clinic_uuid` creates a global holiday and is restricted to the doctor
themselves and admins → otherwise `403 ERR_ACCESS_DENIED` («کلینیک فقط می‌تواند تعطیلی مخصوص خودش را
ثبت کند»). A clinic closing for *all* its doctors is not expressible in this model and needs a
separate `ClinicHoliday` entity — **not implemented**.
### Date overrides are always per-context
`GET /date-override/list/{doctorUuid}?clinic_uuid=…` returns only that context's overrides — no
union, because an override changes working hours and working hours are themselves per-context.
### `GET /available-locations/{doctorUuid}`
Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it,
only that clinic's addresses. The two sets are never merged (they used to be).
+572 -24
View File
@@ -56,7 +56,7 @@ Get all appointment slots (available and booked) for a doctor on a specific date
}
```
> Returns **all** slots grouped by work shift. `is_available: false` means the slot is either already booked (active pending/confirmed appointment) **or** its start time has already passed (for today's date). Session boundaries match the doctor's `WeeklySchedule` or date override config.
> Returns **all** slots grouped by work shift. `is_available: false` means the slot is either taken by a booking **or** its start time has already passed (for today's date). A slot counts as taken when an overlapping appointment is in any *blocking* status (`Appointment::SLOT_BLOCKING_STATUSES`): `confirmed`, `completed`, `following_up`, `salon`, `no_show`, or a still-live `pending` (not yet expired). Only `cancelled_by_user` / `cancelled_by_doctor` / `expired` release the slot. Session boundaries match the doctor's `WeeklySchedule` or date override config.
>
> Returns an **empty** `sessions` array when the date is a holiday, a closed date override, in the past, beyond the doctor's booking window, or when online booking is disabled (see `meta` in `appointment-settings.md`).
@@ -68,6 +68,70 @@ Get all appointment slots (available and booked) for a doctor on a specific date
---
## GET `/api/v1/appointment-service-slots`
زمان‌های خالیِ کافی در **حالت نوبت‌دهی سرویسی** (`booking_mode = service`). برخلاف `/appointment-slots` که اسلاتِ ثابت می‌سازد، این endpoint مدت نوبت را از مجموعِ `duration_minutes` سرویس‌های انتخاب‌شده (+ `buffer_minutes` برنامهٔ هفتگی) می‌گیرد و فضای خالی داخل شیفت‌ها را با رد کردن نوبت‌های اشغال‌شده می‌چیند. فقط سرویس‌های «نمایش در نوبت‌دهی» (`bookable = true`) پذیرفته می‌شوند.
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (uuid) | ✅ | |
| `date` | string `Y-m-d` | ✅ | |
| `service_item_uuids[]` | string[] | ✅ | یک یا چند UUID سرویسِ bookable |
| `durations[<service_uuid>]` | int | ❌ | override مدت (دقیقه) برای همان سرویس — فقط در این محاسبه استفاده می‌شود و مقدار پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند. برای نوبت‌دهیِ منشی که مدت را برای یک نوبت تغییر می‌دهد. مقدار ≤ 0 یا غایب ⇒ مدت پیش‌فرض سرویس |
### Response `200`
```json
{
"success": true,
"data": {
"doctor_uuid": "…",
"date": "2026-07-16",
"total_duration_minutes": 45,
"buffer_minutes": 5,
"start_times": [
{ "start": 1750000000, "end": 1750002700, "start_time": "15:00", "end_time": "15:45", "location_id": 12 }
]
}
}
```
`start_times` خالی یعنی در آن روز فضای کافی نیست. `end` بدونِ بافر است (بافر فقط فاصلهٔ بین نوبت‌های پیشنهادی است). **عمومی** (بدون احراز هویت — مصرف‌کننده: سایت nobat724).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404/422 | Doctor / service item not found |
| `ERR_VALIDATION_001` | 422 | فرمت تاریخ نادرست، پزشک در حالت سرویسی نیست، سرویس bookable نیست، یا مدت سرویس تعریف نشده |
---
## GET `/api/v1/appointment-booking-services/{doctorUuid}`
**عمومی.** روش نوبت‌دهی پزشک + سرویس‌های قابل‌انتخاب برای نوبت‌گیری سرویسی. سایت با این پاسخ تصمیم می‌گیرد مرحلهٔ «انتخاب سرویس» را نشان دهد (حالت `service`) یا جریان اسلاتیِ فعلی (حالت `slot`).
### Response `200`
```json
{
"success": true,
"data": {
"doctor_uuid": "…",
"booking_mode": "service",
"buffer_minutes": 5,
"services": [
{ "uuid": "…", "name": "عصب‌کشی", "duration_minutes": 30, "price_rials": 5000000, "service_section": { "uuid": "…", "name": "دندان" } }
]
}
}
```
`services` فقط سرویس‌های `bookable=true` و فعالِ پزشک را دارد؛ در حالت `slot` معمولاً خالی است.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
---
## GET `/api/v1/appointment-settings/month-availability/{doctorUuid}`
Which days of a month are bookable — used by the public calendar to grey out unavailable days.
@@ -143,7 +207,10 @@ Book an appointment slot.
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
| `slot_end` | integer | | Slot end (Unix timestamp) |
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی `duration_from_services=true` باشد سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه می‌کند؛ در غیر این صورت مقدار کلاینت حفظ می‌شود |
| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس که به نوبت **پیوست** می‌شوند (چند سرویس). اولین سرویس به‌عنوان سرویسِ اصلی (`service_item`) ثبت و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422` |
| `duration_from_services` | boolean | ❌ | `true` = حالت نوبت‌دهی سرویسی: مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. پیش‌فرض `false` (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ می‌شود) |
| `service_durations` | object | ❌ | override مدت هر سرویس به‌صورت `{ "<service_uuid>": <minutes> }` — فقط وقتی `duration_from_services=true`. برای نوبت‌دهیِ منشی که مدت را برای همان نوبت تغییر می‌دهد؛ در محاسبهٔ `slot_end` لحاظ می‌شود و **مقدار پیش‌فرضِ سرویس تغییر نمی‌کند**. مقدار ≤ 0 یا غایب ⇒ مدت پیش‌فرض |
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
@@ -155,9 +222,11 @@ Book an appointment slot.
> **آدرس نوبت:** آدرس (`address_id`) ارسالی نیست؛ سرور آن را از روی `location_id` همان session در برنامه‌ی هفتگی که اسلات در آن قرار دارد، خودکار تعیین و ذخیره می‌کند. در پاسخ به‌صورت `address_id` برمی‌گردد. همه‌ی مسیرهای رزرو (آنلاین `POST /api/v1/appointment`، منشی `POST /api/v1/my/appointment`، ادمین) آدرس را به همین شکل ست می‌کنند.
> **تضمین عدم رزرو دوگانه:** هر سه مسیر رزرو از `AppointmentRepository::bookAtomically()` عبور می‌کنند و یک قید یکتای دیتابیسی (`active_slot_key`) پشت آن قرار دارد؛ بنابراین حتی در شرایط رقابتی (race) فقط یک نوبتِ زنده روی هر `(doctor, slot_start)` ممکن است و درخواست بازنده `409 SLOT_TAKEN` می‌گیرد. نوبت‌های لغو/منقضی اسلات را آزاد می‌کنند (کلید `NULL`).
> **تضمین عدم رزرو دوگانه:** هر سه مسیر رزرو از `AppointmentRepository::bookAtomically()` عبور می‌کنند و یک قید یکتای دیتابیسی (`active_slot_key`) پشت آن قرار دارد؛ بنابراین حتی در شرایط رقابتی (race) فقط یک نوبتِ زنده روی هر `(doctor, slot_start)` ممکن است و درخواست بازنده `409 SLOT_TAKEN` می‌گیرد. نوبت‌های لغو/منقضی اسلات را آزاد می‌کنند (کلید `NULL`). علاوه بر این، `bookAtomically` داخل تراکنش یک قفلِ per-doctor (`PESSIMISTIC_WRITE` روی ردیف پزشک) می‌گیرد؛ چون در **حالت سرویسی** نوبت‌ها طول متغیر و شروعِ متفاوت دارند و قید یکتای `(doctor, slot_start)` تداخلِ بازه‌ایِ دو رزروِ هم‌زمان با شروعِ متفاوت را نمی‌گیرد. این قفل بررسیِ overlap و insert را نسبت به سایر رزروهای همان پزشک اتمیک می‌کند.
> **Auto-add to clinic:** هنگام تأیید نوبت، اگر آدرس نوبت متعلق به یک کلینیک باشد (`DoctorAddress.clinic_id`)، بیمار علاوه بر پرونده‌ی پزشک، به پرونده‌های آن کلینیک هم اضافه می‌شود. اگر آدرس کلینیک نداشت ولی دکتر فقط عضو یک کلینیک بود، به همان کلینیک اضافه می‌شود. هر شاخه مشروط به فعال‌بودن `patient_records`. جزئیات در `docs/api/patient.md`.
>
> **Auto-fill session on confirm:** پرونده‌ای که هنگام تأیید نوبت خودکار ساخته می‌شود، اکنون از خود نوبت پر می‌شود: `session_at` = زمان واقعی نوبت (`slot_start`)، `visit_price_rials` = هزینه ویزیت نوبت (در نبود آن، «قیمت ویزیت آزاد» تنظیمات)، برای هر سرویسِ نوبت یک ردیف `SessionService` با قیمت snapshot از خود سرویس، و `services_total_rials`/`final_price_rials` محاسبه‌شده. قبلاً همه‌ی این مقادیر صفر/خالی ثبت می‌شدند (باگ).
> **Payer vs patient:** the authenticated user (`user`) is always the payer; the `patient_*` fields describe who the visit is for and are stored separately. **Temporary lock:** the slot is held by the new `pending` booking for **15 minutes** (`expires_at = created_at + 900`). If payment is not completed in time, the booking is moved to `expired` and the slot is freed (see `app:cancel-expired-appointments`). An expired pending booking no longer blocks the slot even before the cron runs.
@@ -167,7 +236,7 @@ Book an appointment slot.
"success": true,
"data": {
"uuid": "appt-uuid-...",
"doctor": { "uuid": "...", "name": "دکتر علی احمدی" },
"doctor": { "uuid": "...", "name": "علی احمدی" },
"user": { "uuid": "...", "mobile": "..." },
"slot_start": 1718438400,
"slot_end": 1718439600,
@@ -204,11 +273,40 @@ Book an appointment slot.
---
## Single-appointment access model
`GET /appointment/{uuid}`, `PATCH /appointment/{uuid}`, `PATCH /appointment/{uuid}/status`
and `GET /appointment/{uuid}/events` all resolve access through
`App\Appointment\Security\AppointmentAccessChecker`. The decision is driven by the
appointment's own environment (`appointment.clinic`: `null` = the doctor's personal
office, a value = that clinic) — **not** by the caller's role.
| Caller | Allowed |
|---|---|
| `ROLE_ADMIN` | everything |
| Owning doctor (`appointment.doctor.user`) | everything |
| Patient (`appointment.user`) | `view` and `cancel` only — never reschedule/edit |
| Clinic owner | everything, when `appointment.clinic` is their clinic |
| Member doctor of that clinic | per `ClinicDoctorPermission.appointments.{view,cancel,update_status}`; denied once the row is `active = false` |
| Secretary | active-context scope must match the appointment (same clinic **and** an assigned doctor, or the scope doctor), then `DoctorSecretary.appointments.{view,cancel,update_status}` |
Actions map onto the existing permission vocabulary: reads use `view`; edit / move /
reserve-transfer / replace / non-cancel status changes use `update_status`; any
transition to `cancelled_by_doctor` / `cancelled_by_user` requires `cancel` — including
an inline `status` sent to `PATCH /appointment/{uuid}`. Denials return
`ERR_ACCESS_DENIED` with HTTP `403`.
> Deactivating a doctor in a clinic (`ClinicDoctorPermission.active = false`) or a
> secretary (`DoctorSecretary.active = false`) is the single source of truth for
> "collaboration ended" — both checkers refuse on it. The clinic owner keeps full access.
---
## GET `/api/v1/appointment/{uuid}`
Get appointment detail.
**Permission:** `AUTH`must be the patient, the doctor, or `ROLE_ADMIN`
**Permission:** `AUTH`see [Single-appointment access model](#single-appointment-access-model)
### Path Parameters
| Param | Type | Description |
@@ -223,7 +321,7 @@ Get appointment detail.
"uuid": "appt-uuid-...",
"doctor": {
"uuid": "...",
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"specialties": [
{ "uuid": "...", "name": "اورولوژی عمومی" }
]
@@ -254,7 +352,7 @@ Get appointment detail.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the patient/doctor/admin |
| `ERR_ACCESS_DENIED` | 403 | Caller fails the single-appointment access model |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
---
@@ -263,7 +361,11 @@ Get appointment detail.
Get all appointments for a specific doctor.
**Permission:** `AUTH` must be the doctor, their secretary, or `ROLE_ADMIN`
**Permission:** `AUTH` — the doctor themselves or `ROLE_ADMIN` see every appointment of
that doctor. A clinic user (owner, or member doctor holding `appointments.view`) may also
call it, but the result is **scoped to their own clinic**: only appointments whose
`clinic_id` is that clinic are returned, so the doctor's personal-office appointments
never leak into a clinic. Anyone else gets `403`.
### Path Parameters
| Param | Type | Description |
@@ -273,7 +375,21 @@ Get all appointments for a specific doctor.
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `status` | string | ❌ | Filter: `pending`, `confirmed`, `cancelled`, `completed`, `no_show` |
| `status` | string | ❌ | Single-status filter (legacy) |
| `statuses` | string[] | ❌ | Repeatable: `statuses=pending&statuses=confirmed` |
| `from` | int | ❌ | Unix ts — `slot_start >= from` |
| `to` | int | ❌ | Unix ts — `slot_start <= to` |
| `q` | string | ❌ | Substring match on patient name / mobile (appointment *and* user fields) |
| `service_uuid` | string (UUID) | ❌ | Filter by service item |
| `page` | int | ❌ | Default `1` |
| `limit` | int | ❌ | Default `20`, max `100` |
**Two response shapes.** With **none** of `statuses`/`from`/`to`/`q`/`service_uuid`/`page`/`limit`
present, the legacy nested-array response below is returned unchanged. With **any** of them
present the response is the standard paginated envelope
(`{ success, data: [...], meta: { totalRecords, totalPages, currentPage, limit } }`).
The doctor dashboard filter bar uses the paginated form, defaulting `statuses` to
`pending` + `confirmed` (i.e. "not yet visited").
### Response `200`
```json
@@ -296,7 +412,7 @@ Get all appointments for a specific doctor.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized to view this doctor's appointments |
| `ERR_ACCESS_DENIED` | 403 | Not the doctor/admin, and no clinic scope granting `appointments.view` over this doctor |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
@@ -319,7 +435,7 @@ Get all appointments for the authenticated user.
"data": [
{
"uuid": "...",
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
"doctor": { "uuid": "...", "title": "علی احمدی" },
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "confirmed",
@@ -340,7 +456,11 @@ Get all appointments for the authenticated user.
Change appointment status.
**Permission:** `AUTH`patient can cancel; doctor/secretary can confirm/complete/no_show; admin can do all
**Permission:** `AUTH`see [Single-appointment access model](#single-appointment-access-model).
The required action depends on the target status: a transition to `cancelled_by_doctor` /
`cancelled_by_user` needs `appointments.cancel`, everything else needs
`appointments.update_status`. A clinic secretary therefore confirms and completes by
default but cannot cancel until `cancel` is granted.
### Path Parameters
| Param | Type | Description |
@@ -350,8 +470,9 @@ Change appointment status.
### Request Body
```json
{
"status": "cancelled",
"version": 3
"status": "cancelled_by_doctor",
"version": 3,
"cancel_reason": "بیمار درخواست لغو داد"
}
```
@@ -359,13 +480,19 @@ Change appointment status.
|-------|------|----------|-------------|
| `status` | string | ✅ | New status value |
| `version` | integer | ❌ | Optimistic lock version (prevents double-submit) |
| `cancel_reason` | string | ❌ | Only when transitioning to `cancelled_by_doctor` / `cancelled_by_user`. Stored on the recorded cancellation event (Timeline). Ignored for other statuses. |
**Allowed Transitions by Role:**
| Actor | Allowed transitions |
|-------|---------------------|
| Patient | `pending → cancelled` |
| Doctor / Secretary | `pending → confirmed`, `confirmed → completed`, `confirmed → no_show` |
| Admin | Any transition |
**Allowed Transitions by Actor:** the state machine itself is
`Appointment::ALLOWED_TRANSITIONS` (identical for everyone); the actor only decides
*whether* the transition may be attempted:
| Actor | Allowed |
|-------|---------|
| Patient | cancellation of their own appointment only |
| Doctor (owner) / clinic owner / admin | any transition the state machine permits |
| Member doctor / secretary | non-cancel transitions with `update_status`; cancellations only with `cancel` |
> **Cancellation is logged.** When the status becomes `cancelled_by_doctor` or `cancelled_by_user`, an `AppointmentEvent` (type `cancelled`, title «نوبت لغو شد») is recorded with the actor (user id + name), the optional `cancel_reason`, and the cancel time — surfaced via `GET /api/v1/appointment/{uuid}/events`. A `warning`-level entry is also written to `app_log`.
### Response `200`
Updated appointment object.
@@ -374,17 +501,149 @@ Updated appointment object.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized for this transition |
| `ERR_ACCESS_DENIED` | 403 | Caller lacks `update_status` (or `cancel` for a cancellation) on this appointment |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Invalid status value |
---
## POST `/api/v1/appointment/{uuid}/confirm`
Confirm an appointment («ثبت شده» → «قطعی شده») and register its money on the patient
case file — status transition, case file / visit, and payments in **one atomic
transaction**. If any step fails nothing is committed.
**Permission:** `AUTH``appointments.update_status` per the
[single-appointment access model](#single-appointment-access-model).
### Request Body
```json
{
"version": 3,
"payments": [
{ "method": "cash", "amount_rials": 2000000 },
{ "method": "pos", "amount_rials": 3000000 }
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Each row: `method``wallet\|pos\|cash\|card` and `amount_rials` > 0. Several rows are allowed (split payment). |
The sum of `payments` may not exceed the visit's payable amount → `ERR_SESSION_PAYMENT_EXCEEDS`.
Partial payment is normal: the remainder stays as `remaining_rials` on the visit and can be
collected later through `POST /api/v1/session/{uuid}/payments`.
### What happens on the server
1. `pending → confirmed` (state machine still applies).
2. `AppointmentConfirmationService` files the case file for the appointment's environment
(`appointment.clinic` → clinic, otherwise the doctor's personal office): an **existing**
record for that patient in that environment is reused, otherwise a new one is created.
A `PatientSession` is opened with the visit price and one line per attached service.
3. Each payment row is registered on that visit (`wallet` also debits the patient wallet).
### Response `200`
```json
{
"success": true,
"data": {
"appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
"session": {
"uuid": "…",
"visit_price_rials": 5000000,
"services_total_rials": 1500000,
"final_price_rials": 6500000,
"discount_rials": 0,
"paid_total_rials": 5000000,
"remaining_rials": 1500000,
"is_paid": false
}
}
}
```
`session` is `null` when the tenant does not have the `patient_records` subscription
feature — the appointment is still confirmed, it simply has no case file. **Sending
`payments` in that situation fails with `403 ERR_SUBSCRIPTION_REQUIRED` and confirms
nothing**, because there would be nowhere to record the money.
Reserve-list entries (`is_reserve: true`) never open a visit; move them onto a real slot
first.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | No `update_status` on this appointment |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | Payments sent but the tenant has no `patient_records` feature |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Transition to `confirmed` not allowed from the current status |
| `ERR_SESSION_PAYMENT_INVALID` | 422 | Unknown `method` or non-positive `amount_rials` |
| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | Payments exceed the payable amount |
> **Admin panel:** this is the only path to «قطعی شده». Picking `confirmed` in
> `AppointmentStatusDropdown` opens the «قطعی کردن نوبت» modal rather than issuing a raw
> `PATCH .../status`, so confirmation can never silently skip the case file and payment.
---
## GET `/api/v1/appointment/{uuid}/events`
Appointment Timeline — chronological event history for one appointment. Currently records cancellation events; the structure is generic for future event types.
**Permission:** `IS_AUTHENTICATED_FULLY` — read-only, so it needs `view` (not `update_status`):
see [Single-appointment access model](#single-appointment-access-model). The patient sees
their own Timeline.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Appointment UUID |
### Response `200`
```json
{
"success": true,
"data": [
{
"type": "cancelled",
"title": "نوبت لغو شد",
"actor_name": "دکتر حامد حسینی",
"reason": "بیمار درخواست لغو داد",
"created_at": 1784273931
}
]
}
```
Events are ordered oldest → newest. `data` is a flat array (single nesting). `actor_name` and `reason` may be `null`. `created_at` is a Unix timestamp.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | Not allowed to view this appointment |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
---
## POST `/api/v1/my/appointment`
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
> **Initial status is `pending` («ثبت شده»), not `confirmed`.** Every appointment —
> online, quick, or regular — starts as registered; confirming it is a separate act that
> shows the costs and takes payment (`POST /api/v1/appointment/{uuid}/confirm`). Because
> of that, **no case file / visit is opened at creation time** any more; it is opened on
> confirmation.
>
> A panel-created `pending` appointment still **occupies its slot** (so the time stays
> reserved) and carries **no `expires_at`**, so it is never auto-expired: only online
> gateway holds (created with a 15-minute TTL by `POST /api/v1/appointment`) are swept by
> `AppointmentExpiryService`. Ending a stale registered appointment is an operator
> decision (cancel).
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
> **Scope enforced:** the caller must be related to the target `doctor_uuid`, not merely hold an allowed role. A doctor may book only onto their own calendar; a clinic only onto doctors that belong to it; a secretary only within their active clinic/doctor scope **and** with the `appointments.create` permission; admin onto any. Otherwise `403 FORBIDDEN`.
@@ -397,11 +656,21 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
"slot_end": 1718439600,
"patient_mobile": "09123456789",
"patient_name": "علی محمدی",
"patient_national_code": "0012345678",
"note": "optional note"
}
```
> اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش `ROLE_USER` ساخته می‌شود.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `patient_mobile` | string | ✅ | راه تماس بیمار |
| `patient_name` | string | ✅ | نام بیمار — فقط برای بیمارِ **کاملاً جدید** استفاده می‌شود؛ اگر کد ملی به پروفایلِ موجود بخورد، نامِ همان پروفایل روی نوبت ذخیره و نمایش داده می‌شود و این ورودی نادیده گرفته می‌شود |
| `patient_national_code` | string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
| `visit_price_rials` | int | شرطی | هزینه ویزیت (ریال). اختیاری؛ ولی اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است. روی نوبت ذخیره و در `toArray` با کلید `visit_price_rials` برمی‌گردد |
> **هویت بیمار بر پایه‌ی کد ملی:** کد ملی روی **پروفایل** بیمار ذخیره می‌شود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** پیدا می‌شود، سپس با موبایل. پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که پروفایلش کد ملی دیگری دارد دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ بیماری یافت نشود، کاربر جدید (`ROLE_USER`) به‌همراه پروفایلِ حاملِ همان کد ملی ساخته می‌شود. موبایلِ واردشده در هر نوبت به‌صورت snapshot روی خودِ نوبت (`patient_mobile`) هم ذخیره می‌شود.
>
> **نامِ نمایش‌داده‌شده‌ی بیمار:** چون بیمار با کد ملی به پروفایل واقعی‌اش resolve می‌شود، snapshotِ نامِ نوبت (`patient_name`) از **نامِ همان پروفایل** (`User.realName`) پر می‌شود، نه از نامِ تایپ‌شده در مودال. نامِ ورودی فقط وقتی روی نوبت می‌نشیند که بیمار کاملاً جدید باشد و نامی نداشته باشد. لیستِ `GET /api/v1/my/appointments` هم همین را نشان می‌دهد (`override_name` تنها برای رزروِ عمومیِ «برای شخص دیگر» — که `user` صاحب حساب است — از `realName` جدا می‌شود).
### Response `201`
```json
@@ -420,12 +689,53 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
| Code | HTTP | Description |
|------|------|-------------|
| `FORBIDDEN` | 403 | Role not allowed, or caller not scoped to this doctor |
| `VALIDATION` | 422 | Missing required fields |
| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`), or required `visit_price_rials <= 0` when `require_visit_price` is on (`field: visit_price_rials`) |
| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) |
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
| `SLOT_TAKEN` | 409 | Slot already booked |
---
## GET `/api/v1/my/appointment/patient-lookup`
جستجوی بیمار با شماره موبایل، پیش از ثبت نوبت. فرم ثبت نوبت اول با موبایل جستجو می‌کند؛ اگر بیمار یافت شد و کد ملی دارد، مستقیم استفاده می‌شود، وگرنه کد ملی و نام از کاربر گرفته می‌شود.
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
> برخلاف `GET /api/v1/patient/search-user`، این endpoint به فیچر `patient_records` اشتراک وابسته نیست و `ROLE_ADMIN` را هم می‌پذیرد، چون ثبت نوبت باید مستقل از اشتراک کار کند.
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `mobile` | string | ✅ | شماره موبایل ایران (`^09\d{9}$`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند |
### Response `200` — یافت شد
```json
{
"success": true,
"data": {
"found": true,
"name": "علی محمدی",
"mobile": "09123456789",
"national_code": "0012345678"
}
}
```
> `national_code` ممکن است `null` باشد (بیمار قدیمی بدون کد ملی) — در این حالت فرم کد ملی را می‌گیرد.
### Response `200` — یافت نشد
```json
{ "success": true, "data": { "found": false } }
```
### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
| `FORBIDDEN` | 403 | Role not allowed |
| `VALIDATION` | 422 | Invalid `mobile` (`field: mobile`) |
---
## GET /api/v1/my/appointments
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
@@ -438,9 +748,19 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
| `ROLE_ADMIN` | All appointments |
| `ROLE_CLINIC` | Appointments for doctors in this clinic |
| `ROLE_DOCTOR` | Appointments for this doctor |
| `ROLE_SECRETARY` | Appointments for the linked doctor (empty if `appointments.view` permission is false) |
| `ROLE_SECRETARY` | Appointments of the doctors assigned to this secretary in their active scope (empty if `appointments.view` is false) |
| (plain patient `ROLE_USER`) | The patient's own appointments (`a.user = current user`) |
### GET `/api/v1/my/appointments/today-stats`
Same scoping rules as the list above, aggregated into `{ total, completed, waiting, cancelled }`
for one day (`?date=Y-m-d`, defaults to today).
**Auth:** `IS_AUTHENTICATED_FULLY`. A caller with no resolvable scope (clinic/doctor row
missing, secretary without `appointments.view` or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.
### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
@@ -475,3 +795,231 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
"currentPage": 1
}
}
---
## Clinic workflow extensions (نوبتها Figma)
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name, price_rials}`؛ `price_rials` افزوده شد تا مودالِ «قطعی کردن نوبت» بتواند هزینهها را پیش از ساختهشدنِ مراجعه نشان دهد), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
`pending confirmed|following_up|cancelled_*|expired` · `confirmed completed|following_up|salon|cancelled_*|no_show` · `following_up confirmed|salon|completed|cancelled_*|no_show` · `salon completed|following_up|cancelled_*|no_show`
### PATCH `/api/v1/appointment/{uuid}`
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).
```json
{
"slot_start": 1731000000, "slot_end": 1731001800,
"is_reserve": false,
"service_section_uuid": "…", "service_item_uuid": "…", "staff_uuid": "…",
"deposit_required": true, "deposit_amount_rials": 5000000,
"note": "…", "patient_name": "…", "patient_mobile": "…",
"status": "confirmed", "version": 3
}
```
- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.
- Optimistic lock via `version``409` on concurrent edit.
Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
| HTTP | Description |
|------|-------------|
| 404 | نوبت یافت نشد |
| 403 | `ERR_ACCESS_DENIED` — no `update_status` on this appointment, or an inline cancellation without `cancel` |
| 422 | half slot pair, end < start, unknown relation uuid, invalid transition |
| 409 | slot taken or version conflict |
### POST `/api/v1/my/appointment` (extended)
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `visit_price_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`.
`deposit_amount_rials` **ریال** است (مثل بقیه فیلدهای `_rials`)؛ UI ادمین تومان می‌گیرد و با `tomanToRial` تبدیل می‌کند. داده‌های قدیمی که تومانِ خام ذخیره شده بودند با migration `Version20260717093000` ×۱۰ اصلاح شدند.
`is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`.
`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** می‌شوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمی‌گردند. UUID ناموجود ⇒ `422`.
`duration_from_services: true` (حالت نوبت‌دهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویس‌ها محاسبه و `slot_end` بازنویسی می‌شود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ می‌شود.
`service_durations` (فقط با `duration_from_services=true`): override مدت هر سرویس `{ "<uuid>": <minutes> }` برای همان نوبت (منشی)؛ در `slot_end` لحاظ می‌شود و مقدار پیش‌فرضِ سرویس تغییر نمی‌کند.
> **بخشِ سرویس در `appointment-booking-services`:** هر آیتم `services[]` علاوه بر `uuid/name/duration_minutes/price_rials`، فیلد `service_section: { uuid, name }` هم دارد تا فرمِ نوبت‌دهیِ سرویسی سرویس‌ها را «بخش → سرویس» گروه‌بندی کند. عقب‌رو-سازگار (افزودنِ فیلد).
### GET `/api/v1/my/appointments` (extended)
New query param `reserve=1` → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: `patient_uuid`, `is_reserve`, `deposit_required`, `deposit_amount_rials`, `note`, `service_section`, `service_item`, `staff` (each `{uuid, name|full_name}` or null).
---
## Booking context (2026-07)
A doctor may now hold several booking schedules — one for the personal practice and one per clinic.
Every public booking endpoint therefore accepts an optional **`clinic_uuid`**:
| Endpoint | Where |
|---|---|
| `GET /api/v1/appointment-slots` | query |
| `GET /api/v1/appointment-service-slots` | query |
| `GET /api/v1/appointment-booking-services/{doctorUuid}` | query |
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query |
| `POST /api/v1/appointment` | body |
| `POST /api/v1/my/appointment` | body |
| admin booking (`src/Admin/`) | body |
Omitting it means the **personal practice** — it is never a wildcard. If the doctor is not a member
of the given clinic → `404 ERR_VALIDATION_002` («محل نوبت‌دهی یافت نشد»). All four `GET`s echo back
`clinic_uuid` so a client can tell which context answered.
On `POST /api/v1/appointment`, any `service_item_uuids` must belong to the same context, otherwise
`422 ERR_VALIDATION_001` («سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد»). The appointment's
`address_id` is resolved from that context's schedule.
**The context is stored on the row.** All three booking paths persist it as
`appointments.clinic_id` (`NULL` = personal practice). Downstream consumers — above all the
automatic case-file creation documented in [patient.md](patient.md#auto-creation-on-appointment-confirm) —
read that column instead of inferring the clinic from `address_id`. The old inference had a
fallback of "the doctor's only clinic", which silently filed appointments under the wrong practice
once per-context schedules existed.
### وضعیت اولیهٔ نوبت
| مسیر | وضعیت هنگام ثبت |
|---|---|
| `POST /api/v1/appointment` (سایت عمومی) | `pending` با TTL پرداخت (`Appointment::PAYMENT_TTL` = ۱۵ دقیقه)؛ با پرداخت موفق `confirmed` می‌شود |
| `POST /api/v1/my/appointment` (پنل) | مستقیم `confirmed` |
| admin booking | مستقیم `confirmed` |
نوبتی که خودِ کلینیک/پزشک ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛ `pending` ماندنش
یعنی نه در تقویم درست شمرده می‌شود و نه پرونده می‌سازد.
> **Silent-failure warning:** before this change the location was inferred from the doctor's single
> schedule. A client that does not send `clinic_uuid` will now book into the personal practice —
> which is correct, but is a behaviour change for any doctor who also works in a clinic. Update
> callers before relying on the default.
### GET `/api/v1/appointment-booking-locations/{doctorUuid}`
**Permission:** public — whitelisted in `config/packages/security.yaml` (both the
`public_endpoints` firewall pattern and an `access_control` entry).
Lists every place the doctor can be booked at. The site should show **all** of them, grouped by
location — picking one and hiding the rest removes real capacity from the doctor.
```json
{
"success": true,
"data": {
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"booking_locations": [
{
"location_uuid": "0f0b…",
"type": "personal",
"title": "مطب شخصی",
"address": "یزد، خیابان …",
"clinic_uuid": null,
"booking_mode": "slot",
"buffer_minutes": 0,
"opening_hours": [
{ "day": "Saturday", "opens": "09:00", "closes": "13:00" }
],
"services": [],
"next_available_at": 1755000000
},
{
"location_uuid": "7c21…",
"type": "clinic",
"title": "کلینیک علی بهروزی",
"address": "یزد، بلوار …",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"booking_mode": "service",
"buffer_minutes": 10,
"opening_hours": [
{ "day": "Saturday", "opens": "09:00", "closes": "13:00" },
{ "day": "Sunday", "opens": "16:00", "closes": "20:00" }
],
"services": [
{ "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
"service_section": { "uuid": "…", "name": "عمومی" } }
],
"next_available_at": 1754900000
}
]
}
}
```
| Field | Type | Notes |
|---|---|---|
| `location_uuid` | `string\|null` | the `DoctorAddress` uuid; `null` when the context has no address yet |
| `type` | `"personal" \| "clinic"` | |
| `booking_mode` | `"slot" \| "service"` | per-context — the same doctor can differ between locations |
| `opening_hours` | `array` | active weekly shifts of that context, flattened; each entry is `{day, day_index, location_id, opens, closes}`. `day` is the English weekday name so it maps straight onto schema.org `openingHoursSpecification`; `day_index` is the schedule key (0=Saturday) |
| `available_on_date` | `bool\|null` | only when `?date=` is supplied — whether that location has a free slot that day. `null` without `date` |
| `services` | `array` | populated only in `service` mode, scoped to that context's owner |
| `next_available_at` | `int\|null` | Unix timestamp of the earliest free slot within 30 days, capped by the context's booking window |
`next_available_at` is resolved by `SlotCalculatorService::findNextAvailableStart()`, which fetches
the schedule, holidays, overrides and taken appointments once per location and walks the days in
memory. It counts a reserve appointment as blocking, matching `isSlotTaken()` — the looser
`findBusyIntervals()` used by service-mode slot generation would report such a slot as free.
Sorted by `next_available_at` ascending, so `booking_locations[0]` is the sensible default
selection; locations with no capacity sort last. Deep links should carry the chosen location
(`/doctor/{uuid}?location={location_uuid}`).
**Status codes:** `200`, `404 ERR_VALIDATION_002` (doctor not found).
#### A location must be bookable to be listed
An entry is returned only when **both** hold:
1. the context has at least one registered address, **and**
2. at least one active shift points at one of those addresses.
A schedule whose shifts carry no `location_id`, or point at an address belonging to a different
context (a personal schedule referencing a clinic address, say), is not a place a patient can go —
it is omitted entirely, and its shifts never appear in `opening_hours`.
This filters out rows that predate the `validateSessions` rule, which now rejects such shifts at
write time. Use `php bin/console app:schedule:audit-locations` to list the offenders; `--fix`
deactivates them (it never deletes — the hours are user data). If the schedule was in truth a
clinic's, move it instead with `app:schedule:assign-clinic`.
A related legacy issue is the *shape* of the stored setting: very old rows are a bare JSON list
(`[{"sessions": …}]`) covering only Saturday, so every other weekday reads as day-off and the
`meta` block is missing. `php bin/console app:schedule:normalize-format` reports such rows;
`--fix` rewrites them to the canonical `{"0"…"6", "meta"}` shape without touching the session
data (absent days become `{"sessions": []}`, absent meta becomes the defaults).
#### `?date=YYYY-MM-DD`
Adds `available_on_date` to every entry and echoes `date` in the response. Use it to grey out
locations that cannot be booked on the day the patient picked, rather than showing an empty slot
list. An impossible date (`2026-13-99`) → `422 ERR_VALIDATION_001` on field `date`; the check is a
real calendar check, not just a regex.
`opening_hours` lists one entry per active shift, so a day with a morning and an evening shift
appears twice. Days with no active shift are absent. Times are local `HH:MM` strings, and the
per-shift `location_id` is not repeated here — every shift in an entry already belongs to that
location's context.
**Consumer:** `nobat724_front` — the doctor page must render one booking block per entry, pass the
matching `clinic_uuid` into the slot and booking calls, and feed `opening_hours` into the
`openingHoursSpecification` of each `MedicalClinic` in the Physician JSON-LD.
---
## Why a day has no slots — `empty_reason` (2026-07)
`GET /api/v1/appointment-slots` now returns `empty_reason` alongside `sessions`. It is `null` when
sessions exist, otherwise one of:
| Value | Meaning |
|---|---|
| `no_schedule` | no weekly schedule exists for that **context** — the commonest cause is a caller that forgot `clinic_uuid` and so asked about the personal practice |
| `holiday` | an active holiday covers the day (the doctor's own global holiday, or one the clinic set) |
| `day_off` | a schedule exists but that weekday has no active shift |
| `outside_window` | the date is past, beyond the booking window, or online booking is switched off |
Clients must not translate an empty `sessions` array into "closed". The admin panel used to do
exactly that and reported «این روز تعطیل است» for a doctor whose clinic schedule was perfectly
active — the request simply carried no `clinic_uuid`.
+59 -3
View File
@@ -287,8 +287,20 @@ Authorization: Bearer <token>
"type": "clinic",
"db_uuid": "clinic-uuid-...",
"name": "کلینیک سلامت",
"role": "clinic",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
"role": "doctor",
"scope": "clinic",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"appointment_settings": { "view": true, "update": true },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"services": { "view": true, "update": false },
"clinic_info": { "view": true, "update": false }
}
}
}
]
}
@@ -304,6 +316,18 @@ Authorization: Bearer <token>
| `context` | object\|null | context فعال انتخاب‌شده |
| `available_contexts` | array | همه محیط‌های کاری قابل انتخاب |
**فیلد `permissions` در هر context:**
| حالت context | مقدار `permissions` |
|---|---|
| مطب شخصی پزشک (`type: doctor`، `role: doctor`) | `null` — محیط خودش، محدودیتی ندارد |
| مالک کلینیک (`role: clinic`) | `null` — مالک هرگز محدود نمی‌شود |
| پزشکِ عضو کلینیک (`role: doctor`، `scope: clinic`) | envelope کامل `{version, resources}` از `clinic_doctor_permissions` |
| پزشکِ عضوی که دسترسی‌اش غیرفعال شده | `{version: 1, resources: {}}` — یعنی هیچ دسترسی |
| منشی (`role: secretary`) | envelope کامل از `doctor_secretaries` |
نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.
**قانون `primary_role`** (اولویت‌بندی):
- `ROLE_ADMIN``"admin"`
- `ROLE_CLINIC``"clinic"`
@@ -556,7 +580,7 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
```json
{
"type": "independent_doctor",
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"mobile": "09121234567",
"info": "متخصص داخلی، ۱۰ سال سابقه"
}
@@ -629,6 +653,38 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
---
## POST `/api/v1/user/change-password`
تغییر رمز عبور توسط کاربرِ احرازشده (بدون OTP). رمز فعلی راستی‌آزمایی می‌شود.
**Permission:** `IS_AUTHENTICATED_FULLY`
### Request Body
```json
{
"current_password": "oldpass1234",
"new_password": "newpass1234"
}
```
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `current_password` | string | ✅ | باید با رمز فعلی مطابقت کند |
| `new_password` | string | ✅ | حداقل ۸ کاراکتر و متفاوت با رمز فعلی |
### Response `200`
```json
{ "success": true, "data": { "message": "رمز عبور با موفقیت تغییر یافت" } }
```
### Errors
| HTTP | Code | field | Description |
|------|------|-------|-------------|
| 422 | `ERR_VALIDATION_001` | `new_password` | رمز جدید کوتاه یا برابر رمز فعلی |
| 422 | `ERR_VALIDATION_001` | `current_password` | رمز فعلی نادرست |
---
## POST `/api/v1/user/reset-password`
تغییر رمز عبور با تأیید هویت از طریق OTP.
+243 -12
View File
@@ -2,7 +2,9 @@
> **Prefix:** `/api/v1/billing`
> دامنه: `App\Billing`. مرجع معماری: `docs/architecture/insurance-billing-system.md`.
> tenant از `#[CurrentUser]` resolve می‌شود (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic).
> tenant از `#[CurrentUser]` با **`App\Patient\Security\PatientRecordScopeResolver`** resolve می‌شود — همان رزولوری که پرونده‌های بیمار استفاده می‌کنند، چون صورتحساب و مطالبه از دل مراجعه بیرون می‌آیند و باید در همان محیط دیده شوند. محیط فعال (`UserActiveContext`) تعیین‌کننده است، نه صرفاً ترتیب نقش‌ها. منشیِ فعال هم می‌تواند صورتحساب‌های همان tenant را ببیند/بسازد. جزئیات جدول محیط‌ها: [patient.md](patient.md#record-access-model).
>
> پیش از این، این دامنه ترتیب نقش‌ها را خودش پیاده کرده بود و اول `ROLE_DOCTOR` را می‌گرفت؛ در نتیجه **مالک کلینیکی که خودش پزشک هم هست** به مطب شخصی‌اش نگاشت می‌شد و صورتحساب/مطالبه‌ی کلینیک خودش را `404` می‌گرفت. همین رزولور در `InsuranceController` هم استفاده می‌شود تا قرارداد بیمه و صورتحساب هرگز به دو محیط متفاوت نیفتند.
صورتحساب (`Invoice`) از یک Encounter (`PatientSession`) ساخته می‌شود. برای هر آیتم سهم بیمه‌ی پایه، بیمه‌ی مکمل و بیمار با `BillingCalculator` محاسبه می‌شود:
@@ -67,9 +69,40 @@
## GET /api/v1/billing/invoices/{uuid}
دریافت صورتحساب (فقط مالک tenant).
دریافت صورتحساب (فقط مالک tenant)، غنی‌شده با مراجعه‌ی مبدأ.
**Response 200:** همان ساختار بالا + کلید `session` — خروجی کامل `PatientSession.toArray()` مراجعه‌ای که صورتحساب از آن ساخته شده (برای نمایش «خلاصه فاکتور»: پرداختی‌ها، کالای مصرفی، تخفیف، مبالغ پرداخت‌شده). اگر صورتحساب از session ساخته نشده باشد `session: null`.
```json
{
"success": true,
"data": {
"data": {
"uuid": "…",
"total_rials": 600000,
"status": "finalized",
"items": [ ],
"session": {
"uuid": "…",
"session_at": 1718900000,
"paid_at": 1718990000,
"services_total_rials": 2400000,
"consumables_total_rials": 40000,
"discount_rials": 200000,
"final_price_rials": 2240000,
"paid_total_rials": 1500000,
"payments": [
{ "uuid": "…", "method": "wallet", "amount_rials": 1500000, "paid_at": 1718990000, "created_by_name": "منشی تست", "created_at": 1718990000 }
],
"consumables": [
{ "uuid": "…", "item_name": "عینک", "quantity": 2, "price_rials": 20000, "line_total_rials": 40000 }
]
}
}
}
}
```
**Response 200:** همان ساختار بالا.
**Errors:** `404 ERR_NOT_FOUND_001`.
---
@@ -157,18 +190,117 @@
> این فیلدها با یک کوئری گروهی (`InvoiceItemRepository::detailsForIds` + `PatientSessionRepository::datesForIds`) پر می‌شوند تا N+1 رخ ندهد. همان enrichment روی پاسخ `POST /claims` و `POST /claims/{uuid}/{action}` هم اعمال می‌شود.
## POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. `action``submit|approve|reject|pay`.
## GET /api/v1/billing/claims/by-patient
نمای سطح‌اول داشبورد مطالبات: **یک ردیف به‌ازای هر بیمار** (نه هر مطالبه)، با جمع‌های تجمیعی.
| action | body اختیاری | اثر |
|--------|--------------|-----|
| submit | — | pending → submitted |
| approve | `approved_rials` | submitted → approved (پیش‌فرض = کل ادعا) — باید `0 ≤ approved_rials ≤ total_claimed_rials` |
| reject | `reason` (الزامی) | submitted → rejected |
| pay | `paid_rials` | approved → paid (پیش‌فرض = approved) — باید `0 ≤ paid_rials ≤ total_approved_rials` |
**Query params:**
| Param | Type | Default | توضیح |
|-------|------|---------|-------|
| `page` | int | 1 | شماره صفحه |
| `limit` | int | 20 | حداکثر ۱۰۰ |
| `sort` | string | `last_activity_at` | `full_name` \| `claims_count` \| `total_services_rials` \| `total_insurance_rials` \| `last_activity_at` |
| `dir` | string | `desc` | `asc` \| `desc` |
| `search` | string | — | نام، موبایل یا کد ملی بیمار |
| `status` | string | — | `pending` \| `submitted` \| `approved` \| `rejected` \| `paid` |
| `insurance_id` | int | — | شناسه بیمه |
| `doctor_id` | int | — | پزشکِ نوبتِ مراجعه |
| `payment_status` | string | — | `paid` (وصول‌شده) \| `unpaid` |
| `from` / `to` | int | — | بازه‌ی `claims.created_at` (unix ثانیه) |
پاسخ paginated استاندارد (`{ success, data: [], meta }`):
```json
{
"success": true,
"data": [
{
"patient_uuid": "45064492-...",
"record_uuid": "ad0a3d0e-...",
"full_name": "تست جراحی بینی",
"mobile": "09370671756",
"national_code": null,
"claims_count": 2,
"total_services_rials": 81500000,
"total_insurance_rials": 57050000,
"total_patient_rials": 24450000,
"total_approved_rials": 28000000,
"total_paid_rials": 28000000,
"overall_status": "mixed",
"last_activity_at": 1784404115
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
```
- `overall_status`: اگر همه‌ی مطالبات بیمار یک وضعیت داشته باشند همان؛ وگرنه `mixed`.
- ثابت: `total_services_rials = total_insurance_rials + total_patient_rials`.
- **ضدِ double-counting:** مبالغ خدمات/سهم بیمار از **صورتحساب‌های یکتا** جمع می‌شوند، نه از مطالبات. یک صورتحساب می‌تواند هم‌زمان مطالبه‌ی پایه و مکمل داشته باشد؛ جمع‌زدن از سمت مطالبه مبلغ خدمات را دوبار می‌شمرد.
- مطالبه لینک مستقیم به بیمار ندارد؛ زنجیره‌ی `claim → claim_item → invoice_item → invoice → patient_record` است.
## GET /api/v1/billing/claims/by-patient/{patientUuid}
جزئیات کامل مطالبات یک بیمار (`patientUuid` = **uuid پرونده**، همان `record_uuid` نمای سطح‌اول). فیلترها همان فیلترهای بالا.
```json
{
"success": true,
"data": {
"patient": { "uuid": "...", "record_uuid": "...", "full_name": "...", "mobile": "...", "national_code": null },
"claims": [
{
"uuid": "b244b506-...",
"invoice_uuid": "15498c14-...",
"visit_date": 1784440200,
"doctor_name": "تست",
"insurance_id": 176,
"insurance_name": "تامین اجتماعی",
"insurance_kind": "base",
"service_base_rials": 41000000,
"coverage_percent": 70,
"insurance_share_rials": 28700000,
"patient_share_rials": 12300000,
"total_approved_rials": 28000000,
"total_paid_rials": 28000000,
"status": "paid",
"tracking_number": "TM-4419-88",
"reject_reason": null,
"submitted_at": 1784404200,
"settled_at": 1784404300,
"created_at": 1784404115,
"allowed_transitions": [],
"logs": [
{ "uuid": "...", "from_status": null, "to_status": "pending", "note": "ایجاد مطالبه", "by": null, "at": 1784404115 },
{ "uuid": "...", "from_status": "pending", "to_status": "submitted", "note": null, "by": "علی بهروزی", "at": 1784404200 }
]
}
]
}
}
```
- `coverage_percent` محاسبه‌شده است: `insurance_share_rials / service_base_rials × 100`.
- `allowed_transitions` از `Claim::TRANSITIONS` می‌آید؛ پنل دکمه‌ها را از همین می‌سازد و فهرست مجاز را hardcode نمی‌کند.
- `logs` تاریخچه‌ی کامل تغییر وضعیت از جدول `claim_status_logs` است (به‌ترتیب زمانی صعودی).
**Errors:** `404` پرونده بیمار یافت نشد یا متعلق به tenant دیگری است · `403` پروفایل یافت نشد.
## POST /api/v1/billing/claims/{uuid}/{action}
انتقال وضعیت. `action``submit|approve|reject|pay`. هر انتقال یک ردیف در `claim_status_logs` ثبت می‌کند (وضعیت مبدأ/مقصد، توضیح، کاربر، زمان).
| action | body | اثر |
|--------|------|-----|
| submit | `tracking_number` (اختیاری) | pending → submitted؛ شماره پرونده/پیگیری بیمه روی مطالبه ذخیره می‌شود |
| approve | `approved_rials` (اختیاری) | submitted → approved (پیش‌فرض = کل ادعا) — باید `0 ≤ approved_rials ≤ total_claimed_rials` |
| reject | `reason` (**الزامی**) | submitted → rejected |
| pay | `paid_rials` (اختیاری) | approved → paid (پیش‌فرض = approved) — باید `0 ≤ paid_rials ≤ total_approved_rials` |
`note` (اختیاری) روی همه‌ی اکشن‌ها پذیرفته می‌شود و در تاریخچه ثبت می‌گردد؛ برای `reject` در نبودِ `note` خودِ `reason` ثبت می‌شود.
**Errors:** `422` انتقال نامعتبر، دلیل رد خالی، یا مبلغ `approved_rials`/`paid_rials` خارج از بازه (`field` در پاسخ) · `404` مطالبه یافت نشد.
> اعتبارسنجی انتقال سمت **سرور** انجام می‌شود (`Claim::TRANSITIONS` منبع حقیقت است)؛ مخفی‌کردن دکمه در UI کافی نیست.
## GET /api/v1/billing/reports/insurance-debt
گزارش بدهی بیمه‌ها برای tenant (group بر اساس بیمه).
@@ -185,8 +317,107 @@
```
`debt = claimed - paid` (حداقل صفر).
## GET /api/v1/my/billing/payments
«لیست پرداخت‌ها» — فهرست مسطح صورتحساب‌های ثبت‌شده‌ی همان tenant (هر ردیف یک صورتحساب)، جدیدترین اول. فقط `finalized`/`paid`؛ `draft`/`void` نادیده گرفته می‌شوند.
**Query params:**
| param | توضیح |
|-------|-------|
| `national_code` | جست‌وجوی جزئی روی کد ملی بیمار (`LIKE`) |
| `status` | وضعیت **مشتق‌شده‌ی** پرداخت: `paid` · `partial` · `unsettled` |
| `from` / `to` | بازه‌ی `issued_at` بر حسب ثانیه‌ی Unix |
| `page` / `limit` | صفحه‌بندی (پیش‌فرض ۱ / ۲۰، سقف ۱۰۰) |
**Response 200** (صفحه‌بندی‌شده‌ی مسطح):
```json
{
"success": true,
"data": [
{ "invoice_uuid": "…", "patient_uuid": "…", "patient_name": "دنیا خلیلی",
"national_code": "1744023654", "issued_at": 1717000000,
"amount_rials": 2350000, "paid_rials": 2350000, "status": "paid" }
],
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
}
```
> `amount_rials` = سهم بیمار (`patient_rials`) همان صورتحساب · `paid_rials` = مجموع پرداخت‌های ثبت‌شده‌ی مراجعه‌ی متناظر.
>
> **وضعیت پرداخت مشتق است، ذخیره نمی‌شود.** ستون `invoices.status` فقط چرخه‌ی حیات صورتحساب را نگه می‌دارد (`draft` → `finalized` → `void`) و هرگز `paid` نمی‌شود؛ پول واقعی در `session_payments` ثبت می‌شود. قاعده در `App\Billing\Service\InvoicePaymentStatus` است:
> | حالت | شرط |
> |------|-----|
> | `paid` | `paid_rials >= amount_rials` (شامل صورتحساب صفرریالی) |
> | `partial` | `0 < paid_rials < amount_rials` |
> | `unsettled` | `paid_rials = 0` و `amount_rials > 0` |
>
> صورتحساب بدون مراجعه (`patient_session_id = null`) هیچ پرداختی ندارد، پس `unsettled` می‌ماند.
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
## GET /api/v1/my/billing/payments/summary
خلاصه‌ی مالی **همان مجموعه‌ی فیلترشده‌ی** `GET /api/v1/my/billing/payments` — برای کارت‌های آمار بالای صفحه‌ی «لیست پرداخت‌ها». همان دامنه‌ی رکوردها (فقط `finalized`/`paid` همان tenant).
**Query params:** دقیقاً `national_code`، `status`، `from`، `to` مثل اندپوینت لیست (بدون `page`/`limit`).
**Response 200:**
```json
{
"success": true,
"data": {
"total_rials": 8350000,
"paid_rials": 2350000,
"unsettled_rials": 6000000,
"invoices_count": 2
}
}
```
> مبالغ جمع `patient_rials` هستند. `paid_rials` = جمع صورتحساب‌هایی که **کاملاً** وصول شده‌اند؛ صورتحساب نیمه‌پرداخت (`partial`) کامل در `unsettled_rials` می‌نشیند. `unsettled_rials = total_rials - paid_rials`. با فیلتر `status=paid` مقدار `unsettled_rials` صفر می‌شود (رفتار درست، نه باگ). وقتی هیچ رکوردی مطابقت ندارد، همه‌ی مقادیر `0` برمی‌گردند.
**Errors:** `403` (`ERR_FORBIDDEN_001`) پروفایل tenant یافت نشد.
## GET /api/v1/my/billing/patients/{patientUuid}/invoices
«پرداخت‌های ثبت‌شده» — سربرگ بیمار + فهرست صفحه‌بندی‌شده‌ی صورتحساب‌های `finalized`/`paid` او (جدیدترین اول). فقط مالک رکورد (همان tenant) دسترسی دارد.
**Response 200:**
```json
{
"success": true,
"data": {
"patient": { "uuid": "…", "name": "دنیا خلیلی", "national_code": "1744023654" },
"data": [
{ "uuid": "…", "number": 12345, "issued_at": 1717000000, "total_rials": 2350000,
"patient_rials": 2350000, "paid_rials": 2350000,
"status": "paid", "service_title": "روکش دندان",
"items": [ { "uuid": "…", "title": "روکش دندان", "quantity": 1, "total_rials": 2350000, "patient_rials": 2350000 } ],
"payments": [
{ "method": "cash", "amount_rials": 350000, "paid_at": 1717000000, "created_by_name": "منشی" },
{ "method": "pos", "amount_rials": 2000000, "paid_at": 1717000500, "created_by_name": null }
] }
],
"summary": {
"total_rials": 8350000,
"paid_rials": 2350000,
"unsettled_rials": 6000000,
"invoices_count": 3
},
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
}
```
> `payments` پرداخت‌های ثبت‌شده‌ی مراجعه‌ی همان صورتحساب است (قدیمی‌ترین اول)؛ `method` یکی از `wallet` · `pos` · `cash` · `card` — برچسب فارسی سمت کلاینت در `assets/admin/lib/paymentMethods.ts`. صورتحساب بدون مراجعه آرایه‌ی خالی می‌گیرد.
>
> `status` همان وضعیت مشتق‌شده‌ی بالاست (`paid` · `partial` · `unsettled`) و همیشه بر مبنای `patient_rials` سنجیده می‌شود، حتی در این نما که ستون مبلغش `total_rials` است. `service_title` عنوان اولین آیتم است (+ «و موارد دیگر» اگر بیش از یک آیتم باشد).
>
> `summary` روی **همه‌ی** صورتحساب‌های همان بیمار محاسبه می‌شود (نه فقط صفحه‌ی جاری) و جمع `total_rials` صورتحساب‌هاست — بر خلاف `summary` اندپوینت لیست پرداخت‌ها که سهم بیمار (`patient_rials`) را جمع می‌زند. `invoices_count` همان `meta.totalRecords` است.
**Errors:** `403` پروفایل tenant یافت نشد · `404` (`ERR_NOT_FOUND_001`) بیمار متعلق به این tenant نیست/یافت نشد.
---
## ارسال مطالبه (ClaimSubmitter)
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام می‌شود. پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکت‌های بیمه‌ی ایران کافی است یک پیاده‌سازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمی‌کند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف می‌شود.
عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام می‌شود. پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکت‌های بیمه‌ی ایران کافی است یک پیاده‌سازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمی‌کند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف می‌شود.
## جریان صدور فاکتور در پنل ادمین
«صدور فاکتور» (گام «جزییات» ویزارد مراجعه) و «مشاهده فاکتور» (کارت مراجعه / مودال جزئیات) هر دو از `useIssueInvoice` استفاده می‌کنند: `POST /billing/invoices` (idempotent) و سپس `finalize` اگر `draft` باشد. یعنی session بدون فاکتور، در اولین مشاهده صاحب فاکتور نهایی‌شده می‌شود.
+25 -3
View File
@@ -16,6 +16,7 @@ List published blog posts.
| `page` | integer | ❌ | 1 | Page number |
| `limit` | integer | ❌ | 20 | Items per page |
| `tag` | string | ❌ | — | Filter by exact tag **name** (e.g. `?tag=دیابت`). Blog tags are stored as a JSON array of names; only blogs whose `tags` array contains this exact name are returned. |
| `city_id` | integer | ❌ | — | Scope to one city. Returns that city's posts **plus every nationwide post** (`city_id IS NULL`). Omit it to return all published posts regardless of city. |
### Response `200`
```json
@@ -28,20 +29,37 @@ List published blog posts.
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه مطلب...",
"image": "https://...",
"author": { "uuid": "...", "real_name": "دکتر احمدی" },
"author": { "uuid": "...", "real_name": "احمدی" },
"tags": [{ "id": 1, "name": "دیابت" }],
"status": "published",
"city": { "id": "123", "name": "یاسوج" },
"created_at": 1717000000
}
],
"meta": {
"totalRecords": 25,
"totalPages": 2,
"currentPage": 1
"currentPage": 1,
"limit": 20
}
}
```
### فیلد `city` — شهر پست
`city` در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
| مقدار | معنا |
|-------|------|
| `{ "id": "123", "name": "یاسوج" }` | پست **شهری** — به آن شهر تعلق دارد |
| `null` | پست **سراسری** — حالت دائمی و معتبر، نه «تنظیم‌نشده» |
- `city_id` روی `blogs` **nullable** است و `NULL` معنای دائمی «سراسری» دارد. رکوردهای قبل از این تغییر همگی سراسری شدند.
- حذف شهر پست را حذف نمی‌کند (`ON DELETE SET NULL`) — پست سراسری می‌شود.
- پست سراسری روی **همهٔ** دامنه‌های شهری در لیست دیده می‌شود؛ فقط canonical آن روی دامنهٔ اصلی می‌نشیند. به همین دلیل `city_id=X` هم پست‌های شهر X و هم پست‌های سراسری را برمی‌گرداند — نه فقط شهر X.
> 🔗 مصرف‌کننده: سایت عمومی چند-دامنه‌ای (`nobat724_front`) با همین فیلد تصمیم می‌گیرد پست را روی دامنهٔ شهر canonical کند یا روی دامنهٔ اصلی، و در کدام sitemap بگذارد. تغییر معنای `null` قرارداد آن را می‌شکند.
---
## GET `/api/v1/blog/{slug}`
@@ -66,7 +84,7 @@ Get a single blog post by slug.
"summary": "خلاصه...",
"body": "<p>محتوای کامل...</p>",
"image": "https://...",
"author": { "uuid": "...", "real_name": "دکتر احمدی" },
"author": { "uuid": "...", "real_name": "احمدی" },
"tags": [{ "id": 1, "name": "دیابت" }],
"status": "published",
"created_at": 1717000000,
@@ -108,6 +126,7 @@ Create a new blog post.
| `tags` | integer[] | ❌ | Array of tag IDs |
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
| `image_url` | string | ❌ | Cover image path returned by the upload endpoint |
| `city_id` | integer\|null | ❌ | City this post belongs to. **Omitting it, or sending `null`/`0`, creates a nationwide post.** An unknown city id is rejected with `422`. |
### Response `201`
```json
@@ -168,6 +187,8 @@ Update a blog post.
All fields optional. Send `image_url: ""` to clear the cover image.
`city_id` follows PATCH semantics: **omit it and the post's city is left untouched**; send `null` (or `0`) to turn the post into a nationwide one; send a city id to move it to that city.
### Response `200`
Updated blog object.
@@ -177,6 +198,7 @@ Updated blog object.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Blog not found |
| `ERR_VALIDATION_002` | 422 | Unknown `city_id` |
---
+44 -7
View File
@@ -4,6 +4,19 @@
Admins invite doctors to clinics via SMS. The doctor receives a short (12-char hex) token link valid for 72 hours. Tapping the link opens a server-rendered HTML page (Twig) where the doctor accepts or rejects the invitation (see **Web pages** at the bottom).
### Account provisioning
The invitation flow creates the invitee's account for them — no prior registration is required:
| Stage | `users` row | `doctors` row | Password | `clinic_doctors` link |
|---|---|---|---|---|
| **Invite** | created if missing, gains `ROLE_DOCTOR` | created if missing, `owner_status = unclaimed` | not set | ❌ |
| **Accept** | reused | `owner_status → claimed`, `claimed_at` set | generated **only if the user has none**, then SMS'd | ✅ |
Accept runs inside a single transaction, so an invitation is never marked `accepted` without its doctor profile and clinic link. An existing user's password is **never** overwritten — someone who already has an account simply gets linked to the clinic.
Credentials are sent with the `pre_registration` SMS template: `به کلینیک پرو خوش آمدید! شماره‌کاربری: {username} | رمز عبور: {password} | لینک ورود: {link}`.
---
## POST `/api/v1/admin/clinic/{uuid}/invite-doctor`
@@ -21,7 +34,7 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
```json
{
"mobile": "09123456789",
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"specialty": "قلب و عروق"
}
```
@@ -45,11 +58,14 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
"invited_at": 1717000000,
"expires_at": 1717259200,
"token_used": false,
"doctor": { "uuid": "...", "name": "علی احمدی" },
"clinic": { "uuid": "...", "name": "کلینیک الوند" }
}
}
```
> `doctor` is always populated — the profile is provisioned at invite time (see **Account provisioning**), so the invitee is visible in the panel before they respond.
> SMS is dispatched **asynchronously** via Symfony Messenger → Redis queue.
> SMS text: `"دکتر گرامی، کلینیک {name} شما را برای همکاری دعوت کرده است.\nبرای بررسی: {link}\nاین لینک تا ۷۲ ساعت معتبر است."`
> `{link}` = `{APP_BASE_URL}/i/{token}` — short path + 12-char token to keep the SMS small (a long URL caused Kavenegar `431`).
@@ -175,6 +191,8 @@ Change the status of an invitation (e.g., suspend or remove).
|-------|------|----------|----------------|
| `status` | string | ✅ | `pending`, `suspended`, `removed` |
> Setting `status: "pending"` **reactivates** a suspended invitation: the token is refreshed (the old link stops working) and the invitation SMS is re-sent, so the doctor gets a link that actually works. An invitation that was already `accepted` or `rejected` cannot be returned to `pending`.
### Response `200`
```json
{
@@ -189,6 +207,7 @@ Change the status of an invitation (e.g., suspend or remove).
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Invitation not found |
| `ERR_CONFLICT_001` | 409 | Cannot return an already-answered invitation to `pending` |
| `ERR_VALIDATION_001` | 422 | Invalid status value |
---
@@ -204,8 +223,15 @@ Delete an invitation.
|-------|------|-------------|
| `invUuid` | string (UUID) | Invitation UUID |
### Response `204`
Empty body.
### Response `200`
```json
{
"success": true,
"data": { "message": "دعوتنامه حذف شد" }
}
```
> Hard delete. Returns `200` with the standard envelope rather than a bodyless `204`, so clients can parse every successful response the same way.
### Errors
| Code | HTTP | Description |
@@ -264,14 +290,14 @@ View invitation details by token (used on the doctor-facing landing page).
Doctor accepts the invitation via SMS link.
**Side-effect:** If a doctor profile exists for this mobile, they are added to `clinic_doctors`. If the invitation's doctor FK was null (doctor registered after invite), the match is resolved at accept time using the mobile number.
**Side-effects (single transaction):** resolves — or creates — the `users` + `doctors` pair for the invited mobile, marks the profile `claimed`, adds it to `clinic_doctors`, and sets the invitation to `accepted`. If the user had no password, one is generated and SMS'd so they can log in immediately. See **Account provisioning** at the top.
**Permission:** `PUBLIC`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `token` | string | 96-char hex token |
| `token` | string | 12-char hex token |
### Response `200`
```json
@@ -357,7 +383,7 @@ Doctor accepts or rejects an invitation from their panel (no SMS token needed).
**Permission:** `ROLE_DOCTOR`
**Side-effect on accept:** Doctor is added to `clinic_doctors`. If doctor FK was null at invite time, it is resolved via mobile number at respond time.
**Side-effect on accept:** identical to the public accept endpoint — the doctor profile is claimed and linked to `clinic_doctors` in one transaction.
### Path Parameters
| Param | Type | Description |
@@ -421,4 +447,15 @@ Processes the doctor's choice. **POST only** — accept/reject never happens on
| Unknown `action` | `422` | `expired` page |
| Token not found | `404` | `notfound` page |
> `accept` links the doctor to the clinic **only if** a doctor account exists for the invitation mobile (`accept()` looks it up by mobile). If none exists, the invitation is marked accepted but the doctor must still have/create an account to actually log in.
> `accept` always ends with a usable account: the `users` + `doctors` pair is created when missing, the doctor is linked to the clinic, and login credentials are SMS'd if the user had no password (see **Account provisioning**).
---
## Console: `app:invitations:repair`
Repairs invitations left `accepted` with a null `doctor_id` by the pre-fix `accept()` — creates the missing user/doctor and links them to the clinic without touching the invitation's status.
```bash
ddev exec php bin/console app:invitations:repair --dry-run # report only
ddev exec php bin/console app:invitations:repair # apply
```
+143 -8
View File
@@ -23,6 +23,7 @@
"entity_id": 5,
"name": "آزمایشگاه",
"active": true,
"items_count": 10,
"created_at": 1718000000,
"updated_at": 1718000000
}
@@ -30,6 +31,8 @@
}
```
> `items_count` تعداد سرویس‌های همان بخش است (فقط در این endpoint لیستی برگردانده می‌شود).
---
## POST /api/v1/service-section
@@ -73,6 +76,15 @@
---
## GET /api/v1/service-items
همه‌ی سرویس‌های owner در همه‌ی بخش‌ها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایه‌ی `ServiceItem::toArray`)، مرتب بر نام.
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_FORBIDDEN_001 | 403 | کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابل‌تعیین نیست (ادمین، منشی، نماینده، کاربر عادی) |
## GET /api/v1/service-items/{sectionUuid}
لیست سرویس‌های یک بخش.
@@ -87,9 +99,17 @@
"section_uuid": "...",
"staff_uuid": "...",
"staff_name": "علی محمدی",
"staff": { "uuid": "...", "full_name": "علی محمدی" },
"staff_members": [
{ "uuid": "...", "full_name": "علی محمدی" },
{ "uuid": "...", "full_name": "سحر رحمانی" }
],
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"active": true,
"insurance_covered": false,
"duration_minutes": 50,
"bookable": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
@@ -99,6 +119,89 @@
---
## GET /api/v1/service-item/{uuid}
یک سرویس مشخص — پشتیبان صفحه‌ی اختصاصی «جزئیات سرویس» (`/admin/clinic-services/{uuid}`) که باید با
refresh مستقیم هم کار کند، بنابراین فیلترکردن سمت کلاینت از فهرست کامل کافی نبود.
**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+ · فقط سرویس‌های همان مطب/کلینیک
(`ServiceItem→section→entity_type/entity_id`).
**Response 200:** یک ServiceItem object (ساختار یکسان با آیتم‌های فهرست، شامل `section_uuid`،
`section_name`، `staff_members`، `created_at` و `updated_at`):
```json
{
"success": true,
"data": {
"uuid": "...",
"section_uuid": "...",
"section_name": "تزریقات",
"name": "سرم ۵۰۰cc",
"price_rials": 850000,
"active": true,
"insurance_covered": true,
"duration_minutes": 30,
"bookable": true,
"staff": { "uuid": "...", "full_name": "مریم امینی" },
"staff_members": [{ "uuid": "...", "full_name": "مریم امینی" }],
"inventory_package_uuid": "...",
"inventory_package_title": "پکیج سرم",
"consumables": [
{ "item_uuid": "...", "name": "گاز استریل", "unit": "عدد", "price": 50000, "stock": 100, "amount": 2 }
],
"created_at": 1718000000,
"updated_at": 1718000000
}
}
```
**خطاها:** `404 ERR_SERVICE_NOT_FOUND` — هم برای uuid ناموجود و هم برای سرویس متعلق به tenant دیگر
(وجود سرویس نباید لو برود) · `401` بدون احراز هویت.
> `section_name`، `inventory_package_id`، `inventory_package_uuid`، `inventory_package_title` و `consumables`
> در **همه‌ی** پاسخ‌های سرویس این فایل هستند، نه فقط این endpoint. دو فیلد آخر توسط کنترلر اضافه می‌شوند (نه `toArray()`)
> و پکیج‌ها با یک کوئری batch واکشی می‌شوند تا فهرست سرویس‌ها به N+1 نیفتد.
---
## GET /api/v1/service-item/{uuid}/audit-logs
تاریخچه‌ی تغییرات یک خدمت — تازه‌ترین رویداد اول، حداکثر ۱۰۰ ردیف.
**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+ · فقط سرویس‌های همان مطب/کلینیک.
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"field": "price_rials",
"operation": "update",
"old_value": "700000",
"new_value": "850000",
"actor_name": "دکتر رضایی",
"created_at": 1718000000
}
]
}
```
**فیلدهای رهگیری‌شده** (`ServiceItemAuditService::TRACKED`): `name`، `price_rials`، `active`،
`duration_minutes`، `bookable`، `insurance_covered`، `inventory_package`، `consumables`.
- هر فیلدِ تغییریافته **یک ردیف جدا** می‌سازد؛ فیلد بدون تغییر ردیف نمی‌سازد.
- `operation`: `create` (هنگام ساخت خدمت، فقط یک ردیف روی فیلد `name`) یا `update`.
- مقادیر به‌صورت رشته ذخیره می‌شوند: بولین‌ها `"1"`/`"0"`، مبالغ ریال، و `null` یعنی «بدون مقدار».
ترجمه‌ی نمایشی سمت پنل انجام می‌شود (`FIELD_LABELS` و `auditValue` در `ServiceDetailPage.tsx`).
- جدول `service_item_audit_logs` با `ON DELETE CASCADE` به `service_items` وصل است.
**خطاها:** `404 ERR_SERVICE_NOT_FOUND` · `401`.
---
## POST /api/v1/service-item
ایجاد سرویس جدید.
@@ -113,7 +216,8 @@
"price_rials": 500000,
"staff_uuid": "...",
"insurance_covered": true,
"insurance_price_rials": 200000
"duration_minutes": 50,
"bookable": true
}
```
@@ -121,12 +225,18 @@
|------|-----|--------|
| section_uuid | UUID | ✅ |
| name | string | ✅ |
| price_rials | integer | ❌ (پیش‌فرض 0) |
| staff_uuid | UUID | ❌ |
| insurance_covered | boolean | ❌ (پیش‌فرض false) — آیا خدمت شامل بیمه می‌شود |
| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه |
| price_rials | integer | ❌ (پیش‌فرض 0) — «قیمت پایه» |
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده می‌شود |
| staff_uuid | UUID | ❌ — legacy تک‌پرسنل (اگر `staff_uuids` نباشد استفاده می‌شود) |
| insurance_covered | boolean | ❌ (پیش‌فرض false) — **deprecated برای نوشتن.** پنل ادمین دیگر این فیلد را نمی‌فرستد؛ مقدارش به‌صورت خودکار از ردیف‌های پوشش بیمه همگام می‌شود (به [insurance.md](insurance.md#put-apiv1billingtenant-insurancesuuidservice-coverage) نگاه کن). endpoint هنوز آن را می‌پذیرد تا کلاینت‌های قدیمی نشکنند، ولی ذخیره‌ی پوشش بعداً آن را بازنویسی می‌کند |
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
| bookable | boolean | ❌ (پیش‌فرض false) — «نمایش در نوبت‌دهی». فقط سرویس‌های `bookable=true` در حالت نوبت‌دهی سرویسی قابل‌انتخاب‌اند |
| inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` |
| consumables | array\|null | ❌ — کالاهای **تکی** این خدمت: `[{ "item_uuid": "…", "amount": 2 }]`. **مکمل پکیج است، نه جایگزین آن** — یک خدمت می‌تواند هم‌زمان پکیج و کالای تکی داشته باشد. ارسال این فیلد کل فهرست را **جایگزین** می‌کند (`[]` = حذف همه). هر کالا باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `consumables`. `amount` حداقل ۱ است |
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
> `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته می‌شود.
**Response 201:** ServiceItem object (شامل `insurance_covered`)
> **قیمت واحد:** هنگام ساخت سرویس، یک تعرفه برای **سال جاری** با همان `price_rials` به‌صورت خودکار ثبت می‌شود. قیمت سرویس = تعرفه‌ی سال جاری است و همه‌جا (صورتحساب، مراجعه، مطالبات) از همین قیمت استفاده می‌شود.
@@ -143,11 +253,13 @@
"staff_uuid": null,
"active": false,
"insurance_covered": true,
"insurance_price_rials": 250000
"duration_minutes": 30
}
```
> `staff_uuid` باید به پرسنل متعلق به همان tenant (`entity_type`/`entity_id` کاربر) اشاره کند؛ ربط‌دادن پرسنل tenant دیگر → `422 ERR_VALIDATION_001` (`field: staff_uuid`). همین قید روی `POST /service-item` نیز اعمال می‌شود.
> فیلد `duration_minutes` (زمان متوسط، دقیقه) در پاسخِ `toArray` و در ساخت/ویرایش پشتیبانی می‌شود؛ `""`/`null` آن را پاک می‌کند.
> **پرسنل چندنفره:** یک سرویس می‌تواند چند پرسنل داشته باشد. `staff_uuids` (آرایه) ترجیح داده می‌شود؛ در نبود آن، `staff_uuid` تک‌نفره به‌صورت backward-compatible پذیرفته می‌شود. پاسخ همیشه `staff_members[]` (کامل) و `staff`/`staff_uuid`/`staff_name` (نفر اول، برای سازگاری) را برمی‌گرداند. هر پرسنل باید متعلق به همان tenant (`entity_type`/`entity_id`) باشد؛ در غیر این صورت → `422 ERR_VALIDATION_001` (`field: staff_uuids`). همین قید روی `POST /service-item` نیز اعمال می‌شود.
---
@@ -215,3 +327,26 @@
|------|------|-------|
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
---
## Owner resolution (2026-07)
Every endpoint in this file resolves its owner through `App\Shared\Context\EntityContextResolver`
instead of reading the caller's role directly. Precedence:
1. an explicit **`clinic_uuid`** on the request (query string, or body on `POST`/`PATCH`/`PUT`) —
403 if the caller may not act in that clinic;
2. the caller's stored active context (`user_active_context`);
3. their role.
This fixes a user who is both a doctor and a clinic owner: they used to always resolve as `doctor`
and could never reach their own clinic's services.
`GET /api/v1/service-items?clinic_uuid=…` therefore returns that clinic's services rather than the
caller's personal ones.
> **TODO:** `Inventory`, `Patient`, `Staff`, `Billing`, `Insurance`, `Subscription`, `Tag` and `Sms`
> controllers still carry their own private `resolveEntity()` copy with the old role-first logic.
> They should be migrated to `EntityContextResolver` too.
+156 -6
View File
@@ -8,7 +8,18 @@
Create a new clinic.
**Permission:** `AUTH` — any authenticated user becomes the clinic owner
**Permission:** `AUTH` — any authenticated user becomes the clinic owner, and is
granted `ROLE_CLINIC` on success.
**One clinic per user.** `ClinicRepository::findByUser()` (which resolves the
caller's working context) is a `findOneBy`, so a second clinic would be
unreachable data — the request is rejected instead.
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_VALIDATION_001 | 422 | `name` خالی است (field: `name`) |
| ERR_CONFLICT_001 | 409 | این کاربر از قبل مالک یک کلینیک است |
### Request Body (`application/json`)
```json
@@ -148,10 +159,25 @@ Get clinic detail.
> `city`/`state`/`map` are resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. Each is an array with a single object (or empty `[]` if the clinic has no address). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`).
### معنای `is_active`
`is_active: false` یعنی **«موقتاً غیرفعال»**، نه «حذف‌شده». تصمیم صریح، چون رکورد و نوبت‌های تاریخی‌اش باقی می‌مانند و کلینیک ممکن است دوباره فعال شود.
پیامدها:
- کلینیک غیرفعال همچنان از API برمی‌گردد و لینک مستقیمش **۲۰۰** می‌دهد (نه ۴۰۴/۴۱۰) تا لینک‌های موجود نشکنند.
- سایت عمومی همان صفحه را `noindex` می‌کند و از sitemap بیرون می‌گذارد (`nobat724_front/lib/entityQuality.js``isThinClinic`).
- اگر روزی معنای «حذف‌شده» لازم شد، باید فیلد جداگانه‌ای اضافه شود — نه بازتعریف این یکی.
### نام کلینیک
`name` نمی‌تواند شماره‌تلفن یا مقدار آزمایشی (`test`، `تست`، `-`) باشد؛ این مقادیر با `422` رد می‌شوند (`App\Shared\Util\DisplayName`). `null` مجاز است و یعنی «هنوز نام‌گذاری نشده».
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
| `ERR_VALIDATION_001` | 422 | نام کلینیک شماره‌تلفن یا مقدار آزمایشی است |
---
@@ -159,7 +185,7 @@ Get clinic detail.
Update a clinic.
**Permission:** `AUTH` must be the clinic owner or `ROLE_ADMIN`
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, or a member doctor holding `clinic_info.update` (see **Clinic Doctor Permissions**)
### Path Parameters
| Param | Type | Description |
@@ -280,7 +306,7 @@ Get doctors associated with a clinic.
{
"id": "1207",
"uuid": "...",
"name": "دکتر آرمان رضایی",
"name": "آرمان رضایی",
"gender": "man",
"degree": "specialist",
"img": [],
@@ -289,7 +315,13 @@ Get doctors associated with a clinic.
"point": "4.8",
"free_turn": "پنجشنبه 09:0013:00",
"hours_of_work": "شنبه تا چهارشنبه | پنجشنبه",
"active": true
"active": true,
"city": [
{ "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" }
],
"state": [
{ "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" }
]
}
],
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
@@ -302,6 +334,7 @@ Get doctors associated with a clinic.
| `free_turn` | string | Next available appointment (e.g. `پنجشنبه 09:0013:00`), or `نوبت آزادی موجود نیست` if the doctor has no active weekly schedule |
| `hours_of_work` | string | Working-days summary, or `برنامه کاری تنظیم نشده` when unscheduled |
| `active` | boolean | `true` only when appointments are enabled **and** the doctor has an active schedule |
| `city` / `state` | array | مکان خودِ پزشک (آدرس شخصی، و در نبودش آدرس کلینیک). آرایه با حداکثر یک عضو؛ پزشک بدون آدرس `[]`. جزئیات و قاعدهٔ انتخاب در [doctor.md](doctor.md#city--state-در-پاسخ-لیست) |
> `free_turn`/`hours_of_work`/`active` are computed from each doctor's `WeeklySchedule` (loaded in bulk by the endpoint). Without a schedule they fall back to the "not set" values.
@@ -314,9 +347,9 @@ Get doctors associated with a clinic.
## DELETE `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}`
Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `clinic_doctors` association); it does **not** delete the doctor or change the doctor's own `active` appointment flag.
Detach a doctor from a clinic. This removes the clinic↔doctor link (the `clinic_doctors` association) and the doctor's `clinic_doctor_permissions` row; it does **not** delete the doctor or change the doctor's own `active` appointment flag.
**Permission:** `ROLE_ADMIN`
**Permission:** `AUTH` — the caller must be `ROLE_ADMIN` **or** the owner of this clinic (`ROLE_CLINIC` whose user owns `clinicUuid`). Any other authenticated user gets `403`.
### Path Parameters
| Param | Type | Description |
@@ -332,11 +365,128 @@ Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | Caller is neither an admin nor the clinic owner |
| `ERR_VALIDATION_002` | 404 | Clinic not found |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found, or doctor not linked to this clinic |
---
## Clinic Doctor Permissions
Each doctor attached to a clinic has a permission envelope scoped to **that clinic only** — the doctor's own practice is never affected. Rows live in `clinic_doctor_permissions` (one per clinic+doctor) and are created lazily with defaults for doctors who joined before this feature existed.
The envelope is always returned in full (`{version, resources}`); it is never flattened.
```json
{
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"appointment_settings": { "view": true, "update": true },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"services": { "view": true, "update": false },
"clinic_info": { "view": true, "update": false }
}
}
```
`active: false` revokes everything at once regardless of the individual flags. The clinic owner and `ROLE_ADMIN` bypass all checks and can never be locked out.
Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys.
---
## GET `/api/v1/admin/clinic/{clinicUuid}/doctor-permissions`
List the permission rows of every doctor in the clinic.
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "ce200cde-826d-11f1-b923-c282b864cdcc",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569",
"doctor_name": "تست",
"active": true,
"permissions": { "version": 1, "resources": { "...": {} } },
"created_at": 1784352916,
"updated_at": 1784352916
}
]
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
---
## GET `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions`
Read one doctor's permissions. Creates the row with defaults if it does not exist yet.
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `clinicUuid` | string (UUID) | Clinic UUID |
| `doctorUuid` | string (UUID) | Doctor UUID — must already be attached to this clinic |
### Response `200`
Single permission object (same shape as one item of the list above).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found, or doctor not attached to this clinic |
---
## PATCH `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions`
Update one doctor's permissions. **Deep merge** — only the resources/actions present in the body change; everything else keeps its current value.
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
### Request Body
```json
{
"permissions": { "resources": { "payments": { "create": true } } },
"active": true
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `permissions` | object | ❌ | `{resources: {<resource>: {<action>: bool}}}`. The bare `{<resource>: {...}}` form is also accepted. |
| `active` | bool | ❌ | `false` revokes all access to this clinic |
### Response `200`
Updated permission object.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found, or doctor not attached to this clinic |
| `ERR_VALIDATION_001` | 422 | `permissions` is not an object |
---
## POST `/file/upload/clinic_pro/clinic/field_clinic_logo`
Upload clinic logo.
+80 -4
View File
@@ -16,6 +16,11 @@ Returns stats and today's schedule for the authenticated clinic owner.
|-------|------|---------|-------------|
| `from` | int (unix) | start of current month | Period start for patient/revenue stats |
| `to` | int (unix) | now | Period end for patient/revenue stats |
| `patients_year` | int (Jalali) | current Jalali year | Year of the month drawn by `charts.appointments_by_day` |
| `patients_month` | int `1..12` | current Jalali month | Jalali month drawn by `charts.appointments_by_day` |
| `revenue_year` | int (Jalali) | current Jalali year | Jalali year drawn by `charts.revenue_by_month` |
`patients_year` / `revenue_year` are clamped to `1300..1500` and `patients_month` to `1..12`.
### Response `200`
@@ -36,15 +41,33 @@ Returns stats and today's schedule for the authenticated clinic owner.
"pending_invitations": 2,
"sms_wallet_balance": 50000,
"unique_patients_count": 34,
"revenue_period_rials": 12500000
"total_patients": 210,
"revenue_period_rials": 12500000,
"today_payments_rials": 5225000,
"week_payments_rials": 560000200
},
"charts": {
"revenue_by_day": [
{ "label": "۷ خرداد", "amount_rials": 3200000 }
],
"revenue_by_month": [
{ "label": "فروردین", "amount_rials": 3200000 }
],
"appointments_by_day": [
{ "label": "۱", "count": 9 }
]
},
"charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 },
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string | null",
"doctor_name": "string",
"service_name": "string | null",
"slot_start": 1700000000,
"slot_end": 1700001800,
"status": "reserved"
}
],
@@ -62,8 +85,14 @@ Returns stats and today's schedule for the authenticated clinic owner.
**Field notes:**
- `sms_wallet_balance` — current SMS wallet balance in Rials (0 if wallet not yet created)
- `unique_patients_count` — distinct patients with at least one session in the `from``to` period
- `total_patients` — distinct patients ever (no period filter)
- `revenue_period_rials` — sum of `final_price_rials` from all patient sessions in the period
- `today_appointments` — up to 5 records, ordered by `slot_start ASC`
- `today_payments_rials` / `week_payments_rials` — revenue for today / the last 7 days
- `charts.appointments_by_day` — «نمودار تعداد بیماران»: one entry per day of the requested Jalali month (2831 entries), label = Jalali day number in Persian digits, empty days are `count: 0`
- `charts.revenue_by_month` — «میزان درآمد»: exactly 12 entries, one per Jalali month of `revenue_year`, label = Persian month name
- `charts.revenue_by_day` — last 7 days; kept only as the source of `today_payments_rials` / `week_payments_rials`, not drawn by the dashboard charts
- `charts_period` — the effective (post-clamp) chart period, so the UI can reflect what was actually rendered
- `today_appointments` — up to 5 records, ordered by `slot_start ASC`; each row carries `patient_mobile`, `doctor_name` (personnel), `service_name` (nullable — booked service item), and `slot_end` for the «لیست نوبت‌های جدید» dashboard table (that table renders read-only — `status` is still returned but no longer shown)
- `doctors` — all doctors belonging to this clinic; each includes their appointment count for today
### Errors
@@ -86,6 +115,9 @@ Returns stats and today's schedule for the authenticated doctor.
|-------|------|---------|-------------|
| `from` | int (unix) | start of current month | Period start for patient/revenue stats |
| `to` | int (unix) | now | Period end for patient/revenue stats |
| `patients_year` | int (Jalali) | current Jalali year | Year of the month drawn by `charts.appointments_by_day` |
| `patients_month` | int `1..12` | current Jalali month | Jalali month drawn by `charts.appointments_by_day` |
| `revenue_year` | int (Jalali) | current Jalali year | Jalali year drawn by `charts.revenue_by_month` |
### Response `200`
@@ -106,15 +138,27 @@ Returns stats and today's schedule for the authenticated doctor.
"total_ratings": 34,
"sms_wallet_balance": 25000,
"unique_patients_count": 18,
"revenue_period_rials": 6800000
"total_patients": 140,
"revenue_period_rials": 6800000,
"today_payments_rials": 5225000,
"week_payments_rials": 42000000
},
"charts": {
"revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ],
"revenue_by_month": [ { "label": "فروردین", "amount_rials": 3200000 } ],
"appointments_by_day": [ { "label": "۱", "count": 4 } ]
},
"charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 },
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string",
"doctor_name": "string",
"service_name": "string | null",
"slot_start": 1700000000,
"slot_end": 1700001800,
"status": "reserved"
}
],
@@ -130,10 +174,11 @@ Returns stats and today's schedule for the authenticated doctor.
```
**Field notes:**
- `today_appointments` — up to 10 records, ordered by `slot_start ASC`
- `today_appointments` — up to 10 records, ordered by `slot_start ASC`; each row carries `patient_mobile`, `doctor_name`, `service_name` (nullable), and `slot_end` for the dashboard appointments table
- `avg_rating` — rounded to 1 decimal; `null` if no ratings yet
- `clinics` — all clinics the doctor belongs to
- `sms_wallet_balance`, `unique_patients_count`, `revenue_period_rials` — same semantics as clinic dashboard
- `charts.*`, `charts_period` — same Jalali-period semantics as the clinic dashboard; `revenue_by_day` / `revenue_by_month` are omitted entirely when the caller may not see financials (see the note at the end of this file)
### Errors
@@ -239,3 +284,34 @@ Returns time-series chart data for admin dashboard. All series are filtered to t
- `appointment_status` — all-time counts, not filtered by period
- `top_specialties` — top 8 by appointment volume, all-time
- `subscription_sales_by_plan` — subscriptions created in period, grouped by plan; `revenue` sums only payments with status `received`
---
## Doctor dashboard is context-scoped (2026-07)
`GET /api/v1/dashboard/doctor` now accepts an optional **`clinic_uuid`**. When absent it falls back
to the caller's stored active context (`user_active_context`), then to their role.
In a **clinic context** the response is restricted to that clinic:
* appointment counts and `today_appointments` only include appointments whose `address_id` belongs
to that clinic;
* the financial fields are **omitted entirely** — `revenue_period_rials`, `today_payments_rials`,
`week_payments_rials`, `sms_wallet_balance`, `unique_patients_count`, `total_patients`, and
`charts.revenue_by_day`, and `charts.revenue_by_month`. They describe the doctor's personal practice and have no meaning inside
someone else's clinic. They return only in the personal context, or for the clinic's own owner
holding `payments.view`;
* `clinics` comes back as `[]` — the "کلینیک‌های من" list belongs to the personal dashboard.
A `clinic_uuid` the caller has no access to is ignored and the personal context is used.
New response field:
```json
"context": { "type": "personal" | "clinic", "clinic_uuid": "…|null", "clinic_name": "…|null" }
```
The admin SPA dispatches on this: `primaryRole === 'doctor' && context.scope === 'clinic'` renders
`InvitedDoctorDashboard` (appointment tiles + today's list only) instead of the full doctor
dashboard. Hiding the cards client-side was not enough — the endpoint is directly callable.
+96
View File
@@ -0,0 +1,96 @@
# Discount API
Generic per-tenant discount rules and their evaluation for a patient session.
Owner is resolved from the authenticated user (`ROLE_DOCTOR` → doctor, `ROLE_CLINIC` → clinic). All responses use the shared envelope; single/object payloads are double-nested (`data.data`).
## Rule types (`type`)
| type | target field(s) | meaning |
|------|-----------------|---------|
| `patient_tag` | `target_tag_uuid` (TenantTag uuid) | patient carries the tag |
| `invoice_amount` | `min_amount_rials` | session `final_price_rials` ≥ threshold |
| `specific_patient` | `target_record_uuid` (PatientRecord uuid) | a specific patient's record |
| `occasion` | `valid_from`/`valid_to`, optional `occasion_kind: birthday` | date window; `birthday` also requires today == patient birthday (month/day) |
| `service` | `target_service_item_uuid` (ServiceItem uuid) | session contains that service (discount base = that service's line total) |
| `visit_count` | `min_visit_count` | patient's session count ≥ threshold |
Shared fields: `discount_type` (`percent`|`fixed`), `value` (percent 0..100 or rials), `priority` (int, higher first), `combinable` (bool), `active` (bool), `valid_from`/`valid_to` (unix, nullable).
---
## GET `/api/v1/admin/discount-rules`
List the owner's rules (array hydration, priority desc). **Auth:** doctor/clinic.
### Response `200`
```json
{ "success": true, "data": { "data": [ { "uuid": "…", "name": "بالای ۱۰۰ هزار → ۱۰٪", "type": "invoice_amount", "discount_type": "percent", "value": 10, "priority": 5, "combinable": false, "active": true, "min_amount_rials": 1000000 } ] } }
```
## POST `/api/v1/admin/discount-rules`
Create a rule. **Auth:** doctor/clinic.
### Request Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | ✅ | |
| `type` | string | ✅ | one of the rule types above |
| `discount_type` | string | ❌ | `percent` (default) or `fixed` |
| `value` | int | ❌ | percent or rials |
| `priority` | int | ❌ | default 0 |
| `combinable` | bool | ❌ | default false |
| `active` | bool | ❌ | default true |
| `valid_from` / `valid_to` | int (unix) | ❌ | validity window |
| `target_tag_uuid` / `target_record_uuid` / `target_service_item_uuid` | string (uuid) | ❌ | per-type target |
| `min_amount_rials` / `min_visit_count` | int | ❌ | per-type threshold |
| `occasion_kind` | string | ❌ | `birthday` or null |
### Response `201`
Created rule object (double-nested `data.data`).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | missing name / invalid type |
| `ERR_AUTH_006` | 403 | user is not a doctor/clinic owner |
## PATCH `/api/v1/admin/discount-rules/{uuid}`
Update a rule (owner-scoped). Same body fields (all optional). **Auth:** doctor/clinic.
`404 ERR_VALIDATION_002` if the rule is not found for this owner.
## DELETE `/api/v1/admin/discount-rules/{uuid}`
Delete a rule (owner-scoped). **Auth:** doctor/clinic. Response `200``{ data: { data: { deleted: true } } }`.
---
## GET `/api/v1/session/{uuid}/discount-suggestions`
Evaluate all active owner rules against a session and return applicable discounts. **Auth:** doctor/clinic; the session's record must belong to the caller's owner.
### Response `200`
```json
{
"success": true,
"data": { "data": [
{
"rule_uuid": "740bcfc4-…",
"rule_name": "بالای ۱۰۰ هزار → ۱۰٪",
"type": "invoice_amount",
"discount_type": "percent",
"value": 10,
"discount_rials": 700000,
"combinable": false,
"priority": 5
}
] }
}
```
Ordered by priority desc. Each `discount_rials` is capped at the session's unpaid remainder. Rules that don't apply (or compute to 0) are omitted.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | session not found |
| `ERR_AUTH_006` | 403 | not the owner / not a doctor-clinic user |
---
> Applying a suggested discount goes through `PATCH /api/v1/session/{uuid}` with `discount_rule_uuid` (see `patient.md`); the applied rule is recorded on the session as `applied_discount_rule_id` / `applied_discount_rule_label` for audit and reporting.
+2 -2
View File
@@ -82,7 +82,7 @@ unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق
"data": {
"status": "claimed",
"claim": { "uuid": "…" },
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
"doctor": { "uuid": "…", "name": "فرخنده حسینی" }
}
}
```
@@ -127,7 +127,7 @@ unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق
{
"uuid": "…",
"status": "completed",
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
"doctor": { "uuid": "…", "name": "فرخنده حسینی" },
"mobile_masked": "0912***4567",
"verification_method": "apiir_personinfo+shahkar",
"failure_reason": null,
+1 -1
View File
@@ -50,7 +50,7 @@
```json
{
"name": "دکتر فرخنده حسینی",
"name": "فرخنده حسینی",
"medical_system_code": "145657",
"source": "irimc",
"source_ref": "https://membersearch.irimc.org/member/profile?id=…",
+63 -15
View File
@@ -24,7 +24,7 @@ Create a doctor profile for the authenticated user.
### Request Body (`application/json`)
```json
{
"title": "دکتر علی احمدی",
"title": "علی احمدی",
"gender": "male",
"medical_system_code": "12345",
"degree": "متخصص",
@@ -36,7 +36,7 @@ Create a doctor profile for the authenticated user.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | ✅ | Full name with title |
| `title` | string | ✅ | نام پزشک **بدون** عنوان. پیشوند «دکتر» سمت سرور با `PersianText::stripDoctorTitle()` حذف می‌شود؛ نمایش عنوان کار لایهٔ UI است. |
| `gender` | string | ❌ | `"male"` or `"female"` |
| `medical_system_code` | string | ❌ | Nظام پزشکی code |
| `degree` | string | ❌ | Academic degree |
@@ -51,7 +51,7 @@ Create a doctor profile for the authenticated user.
"success": true,
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"title": "دکتر علی احمدی",
"title": "علی احمدی",
"gender": "male",
"medical_system_code": "12345",
"degree": "متخصص",
@@ -71,6 +71,7 @@ Create a doctor profile for the authenticated user.
| `ERR_AUTH_001` | 401 | Missing or invalid token |
| `ERR_CONFLICT_001` | 409 | Doctor profile already exists for this user |
| `ERR_VALIDATION_002` | 422 | Missing required field |
| `ERR_VALIDATION_001` | 422 | نام پزشک شماره‌تلفن یا مقدار آزمایشی است |
---
@@ -92,7 +93,7 @@ Get doctor detail with clinics.
"data": {
"data": {
"uuid": "550e8400-...",
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"gender": "man",
"medical_system_code": "12345",
"degree": "specialist",
@@ -152,7 +153,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
"data": {
"data": {
"uuid": "...",
"title": "دکتر علی احمدی",
"title": "علی احمدی",
"specialties": [...],
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
}
@@ -174,13 +175,26 @@ Get doctor detail for clinic owner — only doctors who are members of the authe
### Schedule Fields Notes
| Field | When schedule exists | When no schedule |
|-------|---------------------|-----------------|
| `free_turn` | نزدیک‌ترین روز/ساعت کاری از امروز (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
| `hours_of_work` | خلاصه ساعت‌های روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» |
| `active` | `online_booking_enabled && has_active_sessions` | `false` — نوبت‌دهی غیرفعال |
**تجمیع همهٔ برنامه‌ها (2026-07):** این فیلدها روی **همهٔ** برنامه‌های هفتگی پزشک محاسبه
می‌شوند — برنامهٔ مطب شخصی (`clinic_id IS NULL`) به‌علاوهٔ یک برنامه به ازای هر کلینیک.
پزشکی که برنامهٔ شخصی خالی/خاموش ولی برنامهٔ کلینیکیِ فعال دارد، `active=true` می‌گیرد؛
برنامهٔ یک محیط هرگز محیط دیگر را نمی‌پوشاند.
> **نوبت‌دهی آنلاین غیرفعال:** منبعِ فعال/غیرفعال بودن نوبت‌دهی آنلاین، فیلد `meta.online_booking_enabled` در `WeeklySchedule` پزشک است. اگر `false` باشد، صرف‌نظر از سشن‌های برنامه‌ی هفتگی، `free_turn` همیشه `"نوبت‌دهی آنلاین غیرفعال است"` و `active` برابر `false` برمی‌گردد؛ `hours_of_work` در صورت وجود برنامه حفظ می‌شود. چنین پزشکی در لیست عمومی `GET /api/v1/doctors` نمایش داده می‌شود ولی پایین‌تر از پزشکان دارای نوبت قرار می‌گیرد و با فیلتر `active=1` حذف می‌شود؛ صفحه‌ی تکی `GET /api/v1/doctor/{slug}` همچنان قابل دسترسی است.
| Field | When at least one schedule is bookable | When none |
|-------|---------------------------------------|-----------|
| `free_turn` | نزدیک‌ترین روز/ساعت کاری از امروز، بین همهٔ برنامه‌های روشن (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
| `hours_of_work` | خلاصه ساعت‌های همان برنامه‌ای که `free_turn` را داده (ساعت‌های دو محل با هم ترکیب نمی‌شوند) | «برنامه کاری تنظیم نشده» |
| `active` | `activeDoctorAppointment && (∃ schedule: online_booking_enabled && has_active_sessions)` | `false` — نوبت‌دهی غیرفعال |
> **نوبت‌دهی آنلاین غیرفعال:** اگر `meta.online_booking_enabled` در **همهٔ** برنامه‌های پزشک
> `false` باشد، `free_turn` برابر `"نوبت‌دهی آنلاین غیرفعال است"` و `active` برابر `false`
> برمی‌گردد؛ `hours_of_work` در صورت وجود برنامه حفظ می‌شود. تا وقتی حتی یک برنامه روشن و
> دارای روز فعال باشد، همان مبنا قرار می‌گیرد.
>
> چنین پزشکی (همه خاموش) در لیست عمومی `GET /api/v1/doctors` **نمایش داده می‌شود** — این
> اندپوینت فیلتر `active` پیش‌فرض ندارد — ولی با `bookableRank` پایین‌تر از پزشکان دارای
> نوبت مرتب می‌شود و تنها با `active=1` از نتایج حذف می‌گردد. صفحهٔ تکی
> `GET /api/v1/doctor/{slug}` همیشه قابل دسترسی است.
---
@@ -194,7 +208,7 @@ List doctors with pagination and filters.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `limit` | integer | ❌ | Default: 10. **حداکثر ۵۰** — مقادیر بزرگ‌تر بی‌صدا به ۵۰ کاهش می‌یابند. مقدار واقعاً اعمال‌شده در `meta.limit` برمی‌گردد؛ برای پیمایش کامل به `meta.totalPages` تکیه کن، نه به «تعداد آیتم کمتر از limit درخواستی» |
| `search` | string | ❌ | Search in title |
| `specialty_id` | integer | ❌ | Filter by specialty ID |
| `city_id` | integer | ❌ | Filter by city ID — شامل دکترهایی که آدرس شخصی‌شان (`doctor_addresses.city_id`, با `doctor_id` مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) |
@@ -222,7 +236,7 @@ List doctors with pagination and filters.
"data": [
{
"uuid": "...",
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"gender": "man",
"degree": "specialist",
"img": [],
@@ -232,19 +246,53 @@ List doctors with pagination and filters.
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true,
"owner_status": "claimed"
"owner_status": "claimed",
"city": [
{ "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" }
],
"state": [
{ "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" }
]
}
],
"meta": {
"totalRecords": 50,
"totalPages": 3,
"currentPage": 1
"currentPage": 1,
"limit": 50
}
}
```
> ️ `point` و `satisfaction` فقط برای `owner_status="claimed"` مقدار دارند؛ برای `unclaimed`/`pending_transfer` هر دو `null` هستند.
### اعتبارسنجی نام پزشک
`name` نمی‌تواند شماره‌تلفن (`^0?9\d{9}$`) یا مقدار آزمایشی (`test`، `تست`، `-`، `null`) باشد. این مقادیر در **هر** مسیر نوشتن با `422` رد می‌شوند — API عمومی، پنل ادمین، import و دعوت کلینیک — چون گارد روی خودِ Entity نشسته است (`App\Shared\Util\DisplayName`).
دلیل: نام پزشک در `<title>` و نتایج جست‌وجوی سایت عمومی رندر می‌شود؛ رکوردی با نام «09390039833» یک صفحهٔ بی‌ارزش ایندکس‌شدنی می‌سازد.
> دعوت پزشک توسط کلینیک، اگر نام ارسال نشود، دیگر شمارهٔ موبایل را به‌عنوان نام نمی‌نشاند — برچسب خنثای «پزشک دعوت‌شده» می‌گیرد تا خود پزشک پروفایلش را claim کند. (ریشهٔ آلودگی تولیدی همین بود.)
فرمان ممیزی رکوردهای موجود:
```bash
php bin/console app:audit-polluted-records # فقط گزارش
php bin/console app:audit-polluted-records --force # خارج‌کردن از انتشار (بدون حذف)
```
### `city` / `state` در پاسخ لیست
آرایه با حداکثر یک عضو — هم‌شکل با `city`/`state` در پاسخ جزئیات پزشک و پاسخ لیست کلینیک‌ها.
- منبع مکان **دقیقاً همان قاعده‌ای است که فیلتر `city_id`/`state_id` اعمال می‌کند**: اول آدرس شخصی پزشک (`doctor_addresses` با `doctor_id` مقداردار)، و اگر نداشت آدرس کلینیکی که عضو آن است (`doctor_addresses` با `clinic_id` مقداردار و `doctor_id` تهی). یعنی هر پزشکی که با `city_id=X` برگردد، در پاسخ هم همان شهر را اعلام می‌کند.
- پزشک چند-مطبی **یک شهر اصلی** می‌گیرد (اولین مکان یافت‌شده) — نه فهرست همهٔ شهرها.
- پزشک بدون هیچ آدرس: `"city": []` و `"state": []` (آرایهٔ خالی، نه `null`).
- `city[].parent` شناسهٔ استان است.
- استخراج مکان دسته‌ای انجام می‌شود (`DoctorRepository::findLocationsByDoctors`) — حداکثر دو کوئری ثابت، مستقل از تعداد پزشکان در صفحه.
> 🔗 مصرف‌کننده: `nobat724_front/app/sitemap.js` با این فیلد تشخیص می‌دهد هر پزشک به کدام دامنهٔ شهری تعلق دارد (canonical). تغییر شکل این فیلد قرارداد آن را می‌شکند.
---
## PATCH `/api/v1/doctor/{uuid}`
+36 -3
View File
@@ -284,6 +284,13 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `doctor_uuid` | string (UUID) | ❌ | قیمت‌گذاری همان پزشک را برمی‌گرداند به‌جای موجودیت کاربر جاری. برای تب‌های نوبت‌دهی پنل کلینیک. |
با `doctor_uuid`، دسترسی این‌گونه بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دست‌نخورده است.
### Response `200`
```json
{
@@ -292,6 +299,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
"entity_type": "doctor",
"entity_id": 7,
"free_visit_price_rials": 5000000,
"require_visit_price": false,
"insurances": [
{
"insurance_id": 3,
@@ -311,6 +319,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
```
- `patient_share_rials = null` یعنی این بیمه پذیرفته نمی‌شود (قیمت‌گذاری ندارد).
- `require_visit_price` — فلگ «الزامی کردن هزینه ویزیت». وقتی `true` باشد، ثبت مراجعه (session)، فاکتور سرویس و ثبت نوبت بدون هزینه ویزیت (`> 0`) رد می‌شوند.
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل (doctor/clinic) برای کاربر یافت نشد.
@@ -324,9 +333,13 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
**Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`)
### Request Body
> `doctor_uuid` (اختیاری) در بدنه پذیرفته می‌شود و مثل نسخهٔ `GET` عمل می‌کند — همان قواعد دسترسی، با اکشن `services.update`.
```json
{
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"free_visit_price_rials": 5000000,
"require_visit_price": true,
"insurances": [
{ "insurance_id": 3, "patient_share_rials": 1500000 },
{ "insurance_id": 9, "patient_share_rials": null }
@@ -337,23 +350,29 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
| فیلد | نوع | توضیح |
|------|-----|-------|
| `free_visit_price_rials` | int | مبلغ ویزیت آزاد (ریال). اختیاری؛ اگر نباشد تغییر نمی‌کند. |
| `require_visit_price` | bool | فلگ «الزامی کردن هزینه ویزیت». اختیاری؛ اگر نباشد مقدار ذخیره‌شده حفظ می‌شود. |
| `insurances[].insurance_id` | int | شناسه‌ی بیمه (الزامی برای هر ردیف). |
| `insurances[].patient_share_rials` | int \| null | سهم بیمار با این بیمه. `null` → ردیف حذف می‌شود. |
اعتبارسنجی: اگر فلگ مؤثر (ارسالی یا ذخیره‌شده) `true` باشد و قیمت مؤثر (ارسالی یا ذخیره‌شده) `<= 0`، درخواست رد می‌شود.
### Response `200`
همان ساختار `GET /api/v1/insurance-pricing` (وضعیت پس از ذخیره).
### خطاها
- `403` `ERR_FORBIDDEN_001` — پروفایل یافت نشد.
- `422` `ERR_VALIDATION_001` (field: `free_visit_price_rials`) — فلگ الزامی فعال است ولی قیمت ویزیت آزاد `<= 0`.
---
## TenantInsurance — قراردادهای بیمه‌ی tenant (فاز ۱ سیستم صورتحساب)
قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخه‌بندی و وضعیت فعال. مبنای محاسبه‌ی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). tenant از `#[CurrentUser]` (`ROLE_DOCTOR`→doctor، `ROLE_CLINIC`→clinic). جدول `tenant_insurances`.
قرارداد یک پزشک/کلینیک با یک بیمه: درصد پوشش، فرانشیز، سقف تعهد سالانه، نسخه‌بندی و وضعیت فعال. مبنای محاسبه‌ی سهم در سیستم صورتحساب (`docs/architecture/insurance-billing-system.md`). جدول `tenant_insurances`.
tenant از `#[CurrentUser]` با `App\Patient\Security\PatientRecordScopeResolver` resolve می‌شود — همان رزولور پرونده‌ها و صورتحساب‌ها، تا قرارداد بیمه و صورتحسابی که از آن ساخته می‌شود هرگز به دو محیط متفاوت نیفتند. محیط فعال (`UserActiveContext`) تعیین‌کننده است، نه صرفاً ترتیب نقش‌ها؛ مالک کلینیکی که خودش پزشک هم هست، قراردادهای **کلینیک** خود را می‌بیند.
### GET `/api/v1/billing/tenant-insurances`
لیست قراردادهای فعال tenant جاری.
لیست قراردادهای tenant جاری**آخرین نسخهٔ هر بیمه، فعال یا غیرفعال** (برای toggle فعال/غیرفعال در UI مدیریت بیمه). `insurance_kind` = `kind` قرارداد در صورت تعیین، وگرنه نوع بیمه از کاتالوگ.
**Permission:** `AUTH` (doctor/clinic)
@@ -372,6 +391,7 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
"coverage_percent": 70,
"franchise_rials": 0,
"annual_ceiling_rials": null,
"kind": "basic",
"effective_from": 1718900000,
"effective_to": null
}
@@ -390,12 +410,19 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR
| `coverage_percent` | float | درصد پوشش (۰–۱۰۰) |
| `franchise_rials` | int | فرانشیز ثابت سهم بیمار |
| `annual_ceiling_rials` | int \| null | سقف تعهد (null = بی‌نهایت) |
| `kind` | string \| null | نوع بیمه قرارداد (`basic`/`supplementary`); خالی → پیش‌فرض نوع کاتالوگ |
| `effective_from` | int \| null | تاریخ شروع قرارداد (Unix)؛ null → اکنون |
| `effective_to` | int \| null | تاریخ پایان قرارداد (Unix)؛ null → نامحدود |
پاسخ `201`: `{ success, data: { …contract } }`.
خطاها: `404 ERR_NOT_FOUND_001` بیمه یافت نشد · `422 ERR_VALIDATION_001` insurance_id الزامی · `403 ERR_FORBIDDEN_001` پروفایل یافت نشد.
### PATCH `/api/v1/billing/tenant-insurances/{uuid}`
ویرایش `coverage_percent` / `franchise_rials` / `annual_ceiling_rials`. فقط قرارداد متعلق به tenant جاری.
ویرایش فیلدهای قرارداد (همه اختیاری، فقط کلیدهای موجود اعمال می‌شوند). فقط قرارداد متعلق به tenant جاری.
**Body:** `coverage_percent` · `franchise_rials` · `annual_ceiling_rials` · `kind` · `effective_from` · `effective_to` · `is_active`.
- `is_active` (bool): toggle فعال/غیرفعال. برخلاف `DELETE`، مقدار `effective_to`ِ تعیین‌شدهٔ کاربر را دست‌نخورده نگه می‌دارد (برای reactivate).
### DELETE `/api/v1/billing/tenant-insurances/{uuid}`
غیرفعال‌سازی نرم (soft) — `is_active=false` و `effective_to=now`. داده حذف نمی‌شود.
@@ -452,6 +479,12 @@ override پوشش یک خدمت خاص تحت قرارداد یک بیمه. فی
سرویس باید متعلق به همان مطب/کلینیکِ قرارداد باشد (`ServiceItem→section→entity_type/entity_id`).
**اثر جانبی — همگام‌سازی `ServiceItem.insurance_covered`:** پس از ذخیره‌ی ردیف پوشش، پرچم
`insurance_covered` همان خدمت بازمحاسبه می‌شود: اگر زیر **هر** قرارداد بیمه‌ای دست‌کم یک ردیف با
`covered=true` بماند ⇒ `true`، وگرنه `false`. پنل ادمین دیگر این پرچم را دستی نمی‌فرستد (سوییچ «این خدمت
شامل بیمه می‌شود» از فرم سرویس حذف شد)، پس این endpoint تنها منبع حقیقت آن است. پیاده‌سازی:
`TenantInsuranceService::syncServiceItemInsuranceFlag()` + `TenantServiceCoverageRepository::hasActiveCoverage()`.
پاسخ `200`: `{ success, data: { message } }`.
خطاها: `404 ERR_NOT_FOUND_001` قرارداد یافت نشد · `422 ERR_VALIDATION_001` سرویس یافت نشد · `403 ERR_FORBIDDEN_001` سرویس متعلق به شما نیست.
+157
View File
@@ -0,0 +1,157 @@
# Inventory API
> **Prefix:** `/api/v1/inventory-*`
Per-tenant (doctor/clinic) consumable-stock management: **items** and **packages**
(bundles of items). Every row is scoped to the caller's resolved entity
(`doctor` / `clinic`), exactly like Tenant Tags — a tenant only ever sees and
mutates its own inventory. Prices are stored and returned in **Rial** (integer).
**Permission (all routes):** `IS_AUTHENTICATED_FULLY` (roles `doctor`, `clinic`;
`secretary` resolves to its active clinic/doctor context).
Item `status` is **derived, never stored**:
`stock <= 0``out_of_stock`; `stock <= alertThreshold``low_stock`; else `in_stock`.
---
## Items
### GET `/api/v1/inventory-items`
List the tenant's items plus the four derived stat counters.
#### Response `200`
```json
{
"success": true,
"data": {
"items": [
{
"uuid": "…",
"name": "دستکش جراحی",
"consumable": "جراحی",
"category": "لوازم مصرفی و تزریقات",
"unit": "عدد",
"price": 250000,
"stock": 150,
"alertThreshold": 20,
"status": "in_stock"
}
],
"stats": { "total": 1, "low": 0, "inStock": 1, "outOfStock": 0 }
}
}
```
### GET `/api/v1/inventory-categories`
Distinct non-empty `category` values **actually in use** by the tenant — powers the
filter dropdown. For the full list of allowed categories use `inventory-meta`.
#### Response `200`
```json
{ "success": true, "data": ["دارو", "لوازم آزمایشگاهی"] }
```
### GET `/api/v1/inventory-meta`
Backend-owned option lists for the item form. **Single source of truth** — the
admin never hardcodes units/categories. Values are plain Persian strings stored
as-is; the create/update endpoints validate against these lists.
#### Response `200`
```json
{
"success": true,
"data": {
"units": ["عدد", "بسته", "…", "سی‌سی", "میلی‌لیتر", "…"],
"categories": ["دارو", "لوازم مصرفی و تزریقات", "…", "سایر"]
}
}
```
### POST `/api/v1/inventory-item`
Create an item.
#### Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | ✅ | نام کالا |
| `consumable` | string | ❌ | «مصرفی» — یادداشت آزاد (سازگاری عقب‌رو) |
| `category` | string | ❌ | دسته‌بندی؛ باید یکی از `inventory-meta.categories` باشد. خالی → `null`. فرم ادمین آن را الزامی می‌کند |
| `unit` | string | ❌ | باید یکی از `inventory-meta.units` باشد. خالی → پیش‌فرض `عدد` |
| `price` | integer | ❌ | Rial, Default `0` |
| `stock` | integer | ❌ | Default `0` |
| `alertThreshold` | integer | ❌ | Default `0` |
#### Response `201`
```json
{ "success": true, "data": { "uuid": "…", "name": "دستکش جراحی", "status": "in_stock", "...": "..." } }
```
#### Errors
| Status | Code | Cause |
|--------|------|-------|
| `422` | `ERR_VALIDATION_001` | `name` خالی است (field `name`) |
| `422` | `ERR_VALIDATION_001` | `unit` خارج از لیست مجاز (field `unit`) |
| `422` | `ERR_VALIDATION_001` | `category` خارج از لیست مجاز (field `category`) |
| `403` | `ERR_FORBIDDEN_001` | پروفایل tenant یافت نشد |
### PATCH `/api/v1/inventory-item/{uuid}`
Partial update. Any of the create fields may be sent. Returns `200` with the item,
`404 ERR_NOT_FOUND_001` if the item does not belong to the caller, `422` on empty `name`.
### DELETE `/api/v1/inventory-item/{uuid}`
Delete an item. `200` with `{ "message": "کالا حذف شد" }`, or `404` if not owned.
Deleting an item also removes it from any package lines (FK `ON DELETE CASCADE`).
---
## Packages
A package's `total` (Rial) and `available` (boolean) are **derived at read time**
from its component items — `available` is `true` only if every component item has
`stock >= amount`.
### GET `/api/v1/inventory-packages`
#### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "…",
"title": "پکیج شماره یک",
"items": [
{ "itemUuid": "…", "name": "ژل", "unit": "سی‌سی", "price": 1200000, "amount": 2 }
],
"total": 2400000,
"available": true
}
]
}
```
### POST `/api/v1/inventory-package`
#### Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `title` | string | ✅ | نام پکیج |
| `items` | array | ❌ | `[{ "itemUuid": "…", "amount": 2 }]` — references not owned by the caller are silently skipped |
#### Response `201` — the serialized package (same shape as list). Errors: `422 ERR_VALIDATION_001` (empty `title`), `403 ERR_FORBIDDEN_001`.
### PATCH `/api/v1/inventory-package/{uuid}`
Partial update. `title` renames; sending `items` **replaces** all component lines.
`200` with the package, `404 ERR_NOT_FOUND_001` if not owned, `422` on empty `title`.
### DELETE `/api/v1/inventory-package/{uuid}`
`200` with `{ "message": "پکیج حذف شد" }`, or `404` if not owned.
+605 -15
View File
@@ -5,7 +5,46 @@
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)
**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.
---
@@ -17,7 +56,10 @@ Patient records track patients per entity (doctor or clinic). Each record holds
GET /api/v1/patients
```
Returns a paginated list of patient records belonging to the authenticated entity.
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:**
@@ -25,7 +67,15 @@ Returns a paginated list of patient records belonging to the authenticated entit
|-------|------|---------|-------------|
| `page` | int | 1 | Page number |
| `limit` | int | 20 | Items per page (1050) |
| `search` | string | — | Search by patient name or phone |
| `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:**
@@ -37,9 +87,13 @@ Returns a paginated list of patient records belonging to the authenticated entit
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "علی رضایی", "phone": "09123456789" },
"user_uuid": "...",
"user_name": "علی رضایی",
"user_mobile": "09123456789",
"user_national_code": "0012345675",
"record_number": "1024",
"tags": [],
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
],
@@ -51,6 +105,8 @@ Returns a paginated list of patient records belonging to the authenticated entit
}
```
> `user_national_code` منبعِ حقیقتش جدول `profiles` است (نه `users`). اگر روی خودِ کاربر خالی باشد، از پروفایل پر می‌شود؛ اگر هیچ‌کدام نداشته باشند `null` است.
**Errors:**
| Code | HTTP | Description |
@@ -79,11 +135,15 @@ Creates a patient record for a user under the current entity. If the record alre
"user_uuid": "string (اختیاری)",
"mobile": "09xxxxxxxxx (اختیاری — برای جستجو یا ساخت بیمار جدید)",
"name": "string (الزامی فقط هنگام ساخت بیمار جدید)",
"national_code": "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`).
@@ -139,13 +199,23 @@ Returns a single patient record, enriched with the patient's full profile (`prof
"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...",
@@ -169,6 +239,81 @@ Returns a single patient record, enriched with the patient's full profile (`prof
---
### 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
```
@@ -177,7 +322,7 @@ GET /api/v1/patient/{uuid}/sessions
Returns paginated sessions for a patient record.
**Query params:** `page`, `limit` (same as list)
**Query params:** `page`, `limit`, و `filter` = `active` (پیش‌فرض — آرشیوها مخفی) | `all` | `archived`. مقادیر نامعتبر به `active` برمی‌گردند. هر session کلیدهای `archived` (bool) و `archived_at` (unix|null) را هم دارد.
**Response 200:**
@@ -195,8 +340,30 @@ Returns paginated sessions for a patient record.
"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
@@ -206,6 +373,81 @@ Returns paginated sessions for a patient record.
}
```
**فیلدهای غنی‌سازی‌شده (برای تب «سرویس‌ها»):**
| 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 |
@@ -234,24 +476,39 @@ Creates a new visit session for a patient record.
"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` (سهم بیمار) به این صورت محاسبه می‌شود:
- **ویزیت:** `round(visit_price × (1 - base%) × (1 - supp%))` با درصدهای انتخاب‌شده در فرم.
- **ویزیت:** با قاعده‌ی پوشش قرارداد (`TenantInsuranceService::coverageRule`) از طریق `BillingCalculator` — همان مسیری که `InvoiceService` برای صدور فاکتور می‌رود. (تا پیش از این، ویزیت با فرمول درصدی جدا و inline حساب می‌شد و با فاکتور واگرا می‌شد.)
- **هر خدمت:** سهم بیمار با قاعده‌ی پوشش همان بیمه‌گر برای همان خدمت (`TenantServiceCoverage` از طریق `BillingCalculator`) محاسبه می‌شود؛ یعنی فقط خدمتی که بیمه‌ی انتخاب‌شده آن را پوشش می‌دهد تخفیف می‌گیرد (درصد/فرانشیز/سقف؛ مقدار نبودِ override از قرارداد ارث می‌برد). خدمتِ بدون پوشش، کامل بر عهده‌ی بیمار است.
- `final_price_rials = سهم بیمار ویزیت + Σ(سهم بیمار هر خدمت)` و `services_total_rials = Σ(price × quantity)` (قیمت کامل خدمات، بدون بیمه).
- `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 را خراب نمی‌کند (لاگ می‌شود). برای هر صورتحساب فقط یک‌بار مطالبه ساخته می‌شود.
@@ -271,6 +528,7 @@ Creates a new visit session for a patient record.
|------|------|-------------|
| `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`) |
---
@@ -287,10 +545,19 @@ Updates mutable fields on a session.
```json
{
"notes": "...",
"payment_method": "card"
"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
@@ -305,19 +572,342 @@ Updates mutable fields on a session.
| 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
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'`) هم تکرار می‌شود. اگر آدرس نوبت کلینیک نداشت ولی دکتر فقط عضو **یک** کلینیک بود، به همان کلینیک اضافه می‌شود.
1. اگر بیمار در آن محیط پرونده نداشته باشد، یک `PatientRecord` می‌سازد
2. یک `PatientSession` گره‌خورده به همان نوبت می‌سازد (زمان مراجعه = زمان نوبت، هزینه ویزیت
و خطوط سرویس از خود نوبت snapshot می‌شوند)
هر شاخه (doctor / clinic) مستقل و فقط در صورت فعال‌بودن ویژگی `patient_records` برای همان entity اجرا می‌شود. duplicate با `findByEntityAndUser` جلوگیری می‌شود.
### محیط پرونده — یکی، نه هر دو (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` | رکورد/تماس یافت نشد یا مالک دیگر |
+157
View File
@@ -0,0 +1,157 @@
# Payment Methods API
> **Prefix:** `/api/v1/my/payment-methods`
Per-clinic payment methods managed from the settings screen (`/admin/my-financial`,
tab "مدیریت پرداخت"). Two resources: **bank accounts** and **POS (card reader) devices**.
Records are stored so a patient invoice can later reference which account/device a
service payment was made to.
All endpoints are scoped to the acting user — a clinic never sees another's records.
**Permission:** authenticated user with one of `ROLE_CLINIC`, `ROLE_DOCTOR`,
`ROLE_SECRETARY`, `ROLE_ADMIN` (otherwise `403 ERR_FORBIDDEN_001`).
---
## Bank accounts
### GET `/api/v1/my/payment-methods/bank-accounts`
List the current clinic's bank accounts (newest first).
#### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "b1e0...-...",
"bank_name": "ملی",
"card_number": "6037991234567890",
"account_number": "0101234567890",
"shaba_number": "IR820540102680020817909002",
"is_active": true,
"created_at": 1752566400
}
]
}
```
Empty list returns `"data": []`.
---
### POST `/api/v1/my/payment-methods/bank-accounts`
Create a bank account.
#### Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `bank_name` | string | ✅ | Bank name |
| `account_number` | string | ✅ | Account number |
| `card_number` | string | ❌ | Card number |
| `shaba_number` | string | ❌ | IBAN / SHABA |
#### Response `201`
Single created record (same shape as list item).
#### Errors
- `422 ERR_VALIDATION_001``bank_name` or `account_number` missing (`field` set).
---
### PUT `/api/v1/my/payment-methods/bank-accounts/{uuid}`
Update a bank account. Any subset of the create fields may be sent; only provided
keys change. Empty `bank_name`/`account_number``422`.
#### Response `200`
Updated record.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or owned by another clinic.
- `422 ERR_VALIDATION_001` — provided `bank_name`/`account_number` empty.
---
### PATCH `/api/v1/my/payment-methods/bank-accounts/{uuid}/status`
Toggle `is_active` (active ⇄ inactive). No body.
#### Response `200`
Record with flipped `is_active`.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned.
---
## POS devices
### GET `/api/v1/my/payment-methods/pos`
List the current clinic's card reader devices (newest first).
#### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "c2f1...-...",
"bank_name": "ملت",
"serial_number": "SN-98765",
"terminal_number": "123456",
"account_number": null,
"is_active": true,
"created_at": 1752566400
}
]
}
```
---
### POST `/api/v1/my/payment-methods/pos`
Create a POS device.
#### Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `bank_name` | string | ✅ | Bank name |
| `terminal_number` | string | ✅ | Terminal number |
| `serial_number` | string | ❌ | Device serial number |
| `account_number` | string | ❌ | Linked account number |
#### Response `201`
Single created record.
#### Errors
- `422 ERR_VALIDATION_001``bank_name` or `terminal_number` missing (`field` set).
---
### PUT `/api/v1/my/payment-methods/pos/{uuid}`
Update a POS device. Partial update; empty `bank_name`/`terminal_number``422`.
#### Response `200`
Updated record.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned.
- `422 ERR_VALIDATION_001` — provided `bank_name`/`terminal_number` empty.
---
### PATCH `/api/v1/my/payment-methods/pos/{uuid}/status`
Toggle `is_active`. No body.
#### Response `200`
Record with flipped `is_active`.
#### Errors
- `404 ERR_NOT_FOUND_001` — uuid unknown or not owned.
+1 -1
View File
@@ -271,7 +271,7 @@ Get all pending comments waiting for review.
{
"uuid": "...",
"body": "...",
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
"doctor": { "uuid": "...", "title": "علی احمدی" },
"user": { "uuid": "...", "real_name": "..." },
"status": "pending",
"created_at": 1717000000
+5 -5
View File
@@ -380,7 +380,7 @@ Get yearly earnings dashboard for a representation.
#### Request Body
```json
{ "mobile": "0935...", "name": "دکتر ...", "gender": "man", "degree": "...", "medical_system_code": "...", "specialties": [1,2] }
{ "mobile": "0935...", "name": "...", "gender": "man", "degree": "...", "medical_system_code": "...", "specialties": [1,2] }
```
| Field | Type | Required |
|-------|------|----------|
@@ -452,7 +452,7 @@ Get yearly earnings dashboard for a representation.
"patient_name": "...",
"patient_mobile": "0912...",
"doctor_uuid": "...",
"doctor_name": "دکتر ...",
"doctor_name": "...",
"slot_start": 1718000000,
"slot_end": 1718001800,
"appointment_date": "2025-06-15",
@@ -491,7 +491,7 @@ Get yearly earnings dashboard for a representation.
"success": true,
"data": [
{
"uuid": "...", "id": 12, "name": "دکتر ...", "gender": "man", "degree": "...",
"uuid": "...", "id": 12, "name": "...", "gender": "man", "degree": "...",
"medical_code": "...", "mobile": "0912...", "email": null,
"is_active": true, "rate": 3.5, "specialties": [],
"profile_image": null, "created_at": "2026-06-18T..."
@@ -621,7 +621,7 @@ Get yearly earnings dashboard for a representation.
"success": true,
"data": [
{
"uuid": "...", "name": "دکتر ...",
"uuid": "...", "name": "...",
"appointments": { "today": 0, "week": 1, "month": 4, "total": 18 },
"representation_income_rials": 363636,
"subscription_status": "active"
@@ -642,7 +642,7 @@ Get yearly earnings dashboard for a representation.
"success": true,
"data": [
{
"uuid": "...", "appointment_uuid": "...", "doctor_name": "دکتر ...",
"uuid": "...", "appointment_uuid": "...", "doctor_name": "...",
"gross_rials": 2000000, "tax_rials": 45455, "sms_fee_rials": 1500000,
"commission_percent": 20, "representation_share_rials": 90909,
"created_at": "2026-06-24T..."
+146 -25
View File
@@ -14,6 +14,7 @@
- یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا)
- منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد
- scope فعال در runtime از جدول `user_active_context` (db_uuid) خوانده می‌شود
- **محدودسازی به پزشکانِ تخصیص‌یافته:** منشیِ کلینیک فقط نوبت‌های پزشکانی را می‌بیند/رزرو می‌کند که واقعاً به او تخصیص داده شده‌اند — نه همه‌ی پزشکان کلینیک. لیست نوبت (`GET /api/v1/my/appointments`) با `a.doctor IN (پزشکانِ تخصیص‌یافته)` فیلتر می‌شود و گیت رزرو (`POST /api/v1/my/appointment`) رابطه‌ی فعالِ همان (منشی، کلینیک، پزشک) را چک می‌کند. permission رزرو از همان ردیفِ پزشک خوانده می‌شود
Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor.
@@ -31,6 +32,9 @@ Create a secretary for a doctor.
{
"doctor_uuid": "550e8400-...",
"mobile_number": "09123456789",
"name": "سارا احمدی",
"national_code": "1234567890",
"address": "یزد، خیابان تست",
"password": "secretaryPass123",
"permissions": {
"version": 1,
@@ -41,19 +45,31 @@ Create a secretary for a doctor.
"cancel": false,
"update_status": true
},
"patients": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"payments": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"clinic_info": { "view": true, "update": false },
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
"clinic_info": { "view": true, "update": false }
}
}
}
@@ -61,13 +77,38 @@ Create a secretary for a doctor.
| Field | Type | Required | Description |
| --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `mobile_number` | string | ✅ | Secretary's login mobile |
| `doctor_uuid` | string (UUID) | ✅\* | Single doctor to assign (legacy/doctor flow) |
| `doctor_uuids` | string[] (UUID) | ✅\* | **Clinic only** — assign one secretary to several clinic doctors at once. When present (non-empty) and caller is `ROLE_CLINIC`, this multi-doctor path is used instead of `doctor_uuid` |
| `mobile_number` | string | ✅ | Secretary's login mobile. Persian/Arabic digits are accepted and normalized server-side — see [README → Persian digit normalization](README.md#persian-digit-normalization-global) |
| `name` | string | ❌ | Full name (نام + نام خانوادگی) → `user_name` |
| `national_code` | string | ❌ | کد ملی منشی (nullable). Persian/Arabic digits accepted and normalized |
| `address` | string | ❌ | آدرس منشی (nullable) |
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) |
\* یکی از `doctor_uuid` (تک‌پزشکی) یا `doctor_uuids` (چند‌پزشکیِ کلینیک) الزامی است.
**پاسخِ حالت چند‌پزشکی (`doctor_uuids` + `ROLE_CLINIC`) — `201`:**
```json
{
"success": true,
"data": {
"secretary_uuid": "550e8400-...",
"created": [ { "uuid": "...", "secretary_uuid": "...", "doctor_uuid": "...", "...": "..." } ],
"skipped_duplicate": [],
"skipped_limit": [],
"skipped_not_in_clinic": []
}
}
```
- `created`: ردیف‌های تازه‌ساخته/فعال‌شده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ پلنِ آن پزشک پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیه‌ی پزشکان ادامه می‌یابند.
**Permissions Structure:**
مجموعهٔ منابع (resources) بر اساس صفحات موجود پنل ادمین است. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند؛ فقط `appointments` در بک‌اند enforce می‌شود (`MyAppointmentsController`, `DashboardController`)، بقیه UI/ذخیره‌ای هستند.
```json
{
"version": 1,
@@ -78,6 +119,24 @@ Create a secretary for a doctor.
"cancel": false, // Can cancel appointments
"update_status": true // Can mark as completed/no_show
},
"patients": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"payments": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"addresses": {
"view": true,
"create": false,
@@ -87,12 +146,6 @@ Create a secretary for a doctor.
"clinic_info": {
"view": true,
"update": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
}
@@ -107,11 +160,13 @@ Create a secretary for a doctor.
"uuid": "sec-uuid-...",
"user_name": "علی محمدی",
"mobile_number": "09123456789",
"doctor_name": "دکتر احمد رضایی",
"doctor_name": "احمد رضایی",
"doctor_uuid": "...",
"owner_type": "doctor",
"clinic_uuid": null,
"is_active": true,
"national_code": "1234567890",
"address": "یزد، خیابان تست",
"permissions": { ... },
"created_at": 1717000000
}
@@ -160,7 +215,7 @@ Get secretary detail.
"mobile_number": "09123456789",
"active": true,
"permissions": { ... },
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
"doctor": { "uuid": "...", "title": "علی احمدی" },
"created_at": 1717000000
}
}
@@ -178,7 +233,7 @@ Get secretary detail.
## PATCH `/api/v1/secretary/{uuid}`
Update secretary active status or permissions.
Update secretary active status, profile fields (name/national_code/address), or permissions. تمام فیلدها اختیاری‌اند و فقط موارد ارسال‌شده اعمال می‌شوند.
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
@@ -187,6 +242,9 @@ Update secretary active status or permissions.
```json
{
"active": false,
"name": "نام جدید",
"national_code": "9999999999",
"address": "آدرس جدید",
"permissions": {
"version": 1,
"resources": {
@@ -201,10 +259,13 @@ Update secretary active status or permissions.
}
```
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------ |
| `active` | boolean | ❌ | Enable/disable secretary |
| `permissions` | object | ❌ | New permissions object |
| Field | Type | Required | Description |
| --------------- | ------- | -------- | -------------------------------------------- |
| `active` | boolean | ❌ | Enable/disable secretary |
| `name` | string | ❌ | به‌روزرسانی نام کامل منشی (`user_name`) |
| `national_code` | string | ❌ | به‌روزرسانی کد ملی (nullable) |
| `address` | string | ❌ | به‌روزرسانی آدرس (nullable) |
| `permissions` | object | ❌ | New permissions object (deep-merged) |
### Response `200`
@@ -264,7 +325,7 @@ Get all secretaries for a specific doctor.
"uuid": "...",
"user_name": "علی محمدی",
"mobile_number": "09...",
"doctor_name": "دکتر احمد رضایی",
"doctor_name": "احمد رضایی",
"doctor_uuid": "...",
"is_active": true,
"permissions": { ... },
@@ -304,9 +365,10 @@ Get all secretaries across **all doctors** of a clinic.
"data": [
{
"uuid": "...",
"secretary_uuid": "...",
"user_name": "علی محمدی",
"mobile_number": "09...",
"doctor_name": "دکتر احمد رضایی",
"doctor_name": "احمد رضایی",
"doctor_uuid": "...",
"is_active": true,
"permissions": { ... },
@@ -320,7 +382,7 @@ Get all secretaries across **all doctors** of a clinic.
- این endpoint فقط منشی های را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر)
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر). برای گروه‌بندی «یک منشی، چند پزشک» از `secretary_uuid` (uuid کاربرِ منشی) استفاده کنید
### Errors
@@ -332,6 +394,65 @@ Get all secretaries across **all doctors** of a clinic.
---
## PUT `/api/v1/secretaries/clinic/{clinicUuid}/doctors`
هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشیِ کلینیک (owner_type='clinic'): پزشکانِ خواسته‌شده افزوده/فعال و بقیه غیرفعال می‌شوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری.
> ردیف‌های تازه‌ساخته‌شده `national_code`، `address` و `permissions` را از ردیف‌های موجودِ همان منشی کپی می‌کنند تا پروفایل یک شخص روی همه‌ی پزشکانش یکسان بماند. اگر همراه با ویرایش پروفایل صدا زده می‌شود، اول `PATCH /api/v1/secretary/{uuid}` روی ردیف‌های موجود و بعد این اندپوینت را فراخوانی کنید.
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
| ------------ | ------------- | ----------- |
| `clinicUuid` | string (UUID) | Clinic UUID |
### Request Body (`application/json`)
```json
{
"secretary_uuid": "550e8400-...",
"doctor_uuids": ["uuid-doc-a", "uuid-doc-b"]
}
```
| Field | Type | Required | Description |
| ---------------- | --------------- | -------- | ------------------------------------------------------------ |
| `secretary_uuid` | string (UUID) | ✅ | uuid کاربرِ منشی (همان `secretary_uuid` خروجی لیست/ساخت) |
| `doctor_uuids` | string[] (UUID) | ✅ | مجموعه‌ی نهاییِ پزشکان؛ نبودها افزوده، اضافه‌ها غیرفعال می‌شوند |
### Response `200`
```json
{
"success": true,
"data": {
"added": 1,
"removed": 1,
"skipped_limit": [],
"skipped_not_in_clinic": []
}
}
```
| Field | Type | Description |
| ----------------------- | -------- | ------------------------------------------------ |
| `added` | int | تعداد ردیف‌های افزوده/فعال‌شده |
| `removed` | int | تعداد ردیف‌های غیرفعال‌شده |
| `skipped_limit` | string[] | uuid پزشکانی که به سقفِ پلن رسیده‌اند (نادیده گرفته) |
| `skipped_not_in_clinic` | string[] | uuid پزشکانی که عضو این کلینیک نیستند |
### Errors
| Code | HTTP | Description |
| -------------------- | ---- | ------------------------------------ |
| `ERR_AUTH_006` | 403 | Not clinic owner nor admin |
| `ERR_VALIDATION_001` | 422 | `secretary_uuid`/`doctor_uuids` missing |
| `ERR_VALIDATION_002` | 404 | Clinic or secretary not found |
---
## محدودیت پنل اشتراکی
تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود:
+1 -1
View File
@@ -62,7 +62,7 @@ Send an SMS using an approved template.
"mobile": "09123456789",
"template_uuid": "tmpl-uuid-...",
"vars": {
"name": "دکتر علی احمدی",
"name": "علی احمدی",
"date": "۱۵ خرداد ۱۴۰۴"
},
"provider": "kavenegar"
+2 -1
View File
@@ -95,8 +95,9 @@
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_TRIAL_ALREADY_USED | 422 | قبلاً از تریال استفاده شده |
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است (SiteConfig: trial_enabled=0) |
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است — یا `SiteConfig: trial_enabled=0`، یا پلن `basic` هیچ دورهٔ تریالِ `active` ندارد |
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
| ERR_NOT_FOUND_001 | 500 | پلن `basic` وجود ندارد یا غیرفعال است (نصب ناقص) |
---
+37
View File
@@ -144,3 +144,40 @@ Full-table JSON export and strict wipe+replace import for this category live und
The admin list endpoint accepts `sort=id&order=asc|desc` to order by `id`
(used by the admin «دسته‌بندی‌ها» page when clicking the «شناسه» column).
Without `sort`, the default ordering (weight/name) is unchanged.
---
## برچسب‌های Tenant (doctor/clinic)
برچسب‌های اختصاصیِ هر tenant با رنگ نمایش — جدا از taxonomy سراسری بالا. همه به entity کاربر (`doctor`/`clinic`) scope می‌شوند؛ هر tenant فقط برچسب‌های خودش را می‌بیند/تغییر می‌دهد.
**Permission:** `IS_AUTHENTICATED_FULLY` (doctor/clinic/secretary)
### GET `/api/v1/tenant-tags`
لیست برچسب‌های tenant جاری. Response: `{ success, data: [{ uuid, name, color, active }] }`
### POST `/api/v1/tenant-tag`
```json
{ "name": "فوری", "color": "#FF0000", "active": true }
```
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | ✅ | غیرخالی، حداکثر ۶۰ |
| `color` | string | ❌ | هگز `#RRGGBB` یا `#RRGGBBAA` (پیش‌فرض `#5559CE`) |
| `active` | bool | ❌ | وضعیت اولیه (پیش‌فرض `true`) |
Response `201`: TenantTag object.
### PATCH `/api/v1/tenant-tag/{uuid}`
فیلدهای اختیاری `name` / `color` / `active`. فقط مالک؛ در غیر این صورت `404`.
### DELETE `/api/v1/tenant-tag/{uuid}`
حذف برچسب. فقط مالک؛ در غیر این صورت `404`.
### Errors
| HTTP | Code | field | Description |
|------|------|-------|-------------|
| 422 | `ERR_VALIDATION_001` | `name` | نام خالی |
| 422 | `ERR_VALIDATION_001` | `color` | رنگ نامعتبر |
| 404 | `ERR_NOT_FOUND_001` | — | برچسب یافت نشد یا متعلق به tenant دیگر |
| 403 | `ERR_FORBIDDEN_001` | — | پروفایل tenant یافت نشد |