# Admin API

> **Prefix:** `/api/v1/admin`  
> **Permission:** ALL endpoints in this file require `ROLE_ADMIN`  
> **Headers:** `Authorization: Bearer <admin_jwt_token>`

---

## Dashboard

### GET `/api/v1/admin/dashboard/stats`

Get key performance indicators (KPIs) for the dashboard.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "total_users": 1200,
    "active_doctors": 85,
    "total_doctors": 92,
    "total_clinics": 34,
    "today_appointments": 47,
    "total_appointments": 8540,
    "today_payments_count": 30,
    "today_payments_amount": 15000000,
    "total_payments_amount": 425000000,
    "pending_comments": 12,
    "pending_settlements": 5,
    "this_month_revenue": 52000000,
    "this_month_appointments": 620
  }
}
```

---

### GET `/api/v1/admin/dashboard/charts`

Get chart data for the last 30 days.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "appointments_30d": [
      { "date": "2024-06-01", "count": 42 }
    ],
    "revenue_30d": [
      { "date": "2024-06-01", "amount_rials": 21000000 }
    ],
    "appointment_status": {
      "confirmed": 350,
      "completed": 180,
      "cancelled": 45,
      "pending": 20,
      "no_show": 25
    },
    "top_specialties": [
      { "name": "قلب و عروق", "count": 120 }
    ]
  }
}
```

---

### GET `/api/v1/admin/dashboard/recent`

Get recent activity (last 10 of each type).

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "appointments": [
      {
        "uuid": "...",
        "doctor_title": "دکتر علی احمدی",
        "patient_name": "محمد رضایی",
        "slot_start": 1718438400,
        "status": "confirmed"
      }
    ],
    "payments": [
      {
        "uuid": "...",
        "amount_rials": 500000,
        "gateway": "mellat",
        "status": "paid",
        "created_at": 1717000000
      }
    ],
    "users": [
      {
        "uuid": "...",
        "real_name": "محمد رضایی",
        "mobile_number": "09...",
        "roles": ["ROLE_USER"],
        "created_at": 1717000000
      }
    ]
  }
}
```

---

## User Management

### GET `/api/v1/admin/users`

List all users with pagination and filters.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search by name or mobile |
| `role` | string | ❌ | فیلتر نقش: `admin` \| `doctor` \| `secretary` \| `clinic` \| `representation` (کاربران دارای `ROLE_REPRESENTATION`) \| `patient` |
| `status` | string | ❌ | `"active"` or `"inactive"` |
| `sort` | string | ❌ | `"created_at"` (default desc) |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "real_name": "علی احمدی",
      "mobile_number": "09123456789",
      "roles": ["ROLE_USER"],
      "status": "active",
      "created_at": 1717000000
    }
  ],
  "meta": { "totalRecords": 1200, "totalPages": 60, "currentPage": 1 }
}
```

---

### GET `/api/v1/admin/users/{uuid}`

Get detailed user info.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "uuid": "...",
    "real_name": "علی احمدی",
    "mobile_number": "09123456789",
    "roles": ["ROLE_USER"],
    "status": "active",
    "wallet_balance_rials": 500000,
    "appointments_count": 5,
    "created_at": 1717000000
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | User not found |

---

### GET `/api/v1/admin/users/stats`

Get user statistics.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "total": 1200,
    "active": 1150,
    "inactive": 50,
    "admins": 3,
    "doctors": 92,
    "patients": 1100
  }
}
```

---

### PUT `/api/v1/admin/users/{uuid}`

Update user info (name, email, password).

**Permission:** `ROLE_ADMIN`

### Request Body (`application/json`)
```json
{
  "real_name": "علی احمدی جدید",
  "password": "newPassword123"
}
```

### Response `200`
Updated user object.

---

### PUT `/api/v1/admin/users/{uuid}/role`

Change a user's role.

**Permission:** `ROLE_ADMIN`

### Request Body (`application/json`)
```json
{
  "role": "ROLE_DOCTOR"
}
```

| Field | Type | Required | Allowed Values |
|-------|------|----------|----------------|
| `role` | string | ✅ | `ROLE_USER`, `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN` |

### Response `200`
```json
{ "success": true, "data": { "message": "نقش کاربر تغییر کرد", "roles": ["ROLE_DOCTOR"] } }
```

---

### POST `/api/v1/admin/users/{uuid}/status`

Toggle user active/inactive status.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{ "success": true, "data": { "status": "inactive" } }
```

---

### DELETE `/api/v1/admin/users/{uuid}`

Delete a user.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{ "success": true, "data": { "message": "کاربر حذف شد" } }
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | User not found |

---

## Doctor Management

> **نماینده (ROLE_REPRESENTATION):** افزودن پزشک و کلینیک برای نماینده از طریق endpointهای جدا انجام می‌شود — `POST /api/v1/representation/doctor` و `POST /api/v1/representation/clinic` (به `docs/api/representation.md` مراجعه کنید). در نسخه‌ی نماینده، `representation_id` پزشک خودکار روی نماینده‌ی کاربر جاری ست می‌شود. endpointهای `/api/v1/admin/*` همچنان فقط `ROLE_ADMIN` هستند.

### POST `/api/v1/admin/doctors`

ساخت پزشک جدید (در صورت نبودِ کاربر با این موبایل، یک User هم ساخته می‌شود).

**Permission:** `ROLE_ADMIN`

#### Request Body (`application/json`)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mobile` | string | ✅ | موبایل ورود؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) — ارقام فارسی/عربی به انگلیسی نرمال می‌شوند |
| `name` | string | ✅ | نام پزشک |
| `gender` / `degree` / `medical_system_code` / `info` | string | ❌ | اطلاعات حرفه‌ای |
| `activity_time` | integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ سال تجربه |
| `specialties` | integer[] | ❌ | آرایهٔ IDهای تخصص. هر شناسهٔ فرزند سمت سرور با تمام والدهایش تا ریشهٔ درخت گسترش می‌یابد؛ شناسه‌های ناموجود نادیده گرفته می‌شوند. |

#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION` | 422 | `mobile` یا `name` خالی |
| `VALIDATION` | 422 | `mobile` فرمت معتبر موبایل ایران ندارد (`field: mobile`) |

---

### POST `/api/v1/admin/clinic`

ساخت کلینیک جدید (در صورت نبودِ کاربرِ صاحب با این موبایل، یک User هم ساخته می‌شود).

**Permission:** `ROLE_ADMIN`

#### Request Body (`application/json`)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `owner_mobile` | string | ✅ | موبایل صاحب کلینیک؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) |
| `name` | string | ✅ | نام کلینیک |

#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION` | 422 | `owner_mobile` خالی |
| `VALIDATION` | 422 | `owner_mobile` فرمت معتبر موبایل ایران ندارد (`field: owner_mobile`) |
| `VALIDATION` | 422 | `name` کلینیک خالی |

---

### GET `/api/v1/admin/doctors`

List all doctors with pagination.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search in title |
| `status` | string | ❌ | `"active"` or `"inactive"` |
| `gender` | string | ❌ | `"male"` or `"female"` |
| `specialty_id` | integer | ❌ | Filter by specialty |
| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایل‌های ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) |
| `unassigned` | string | ❌ | `1` → فقط پزشکانِ بدون نماینده (`representation_id IS NULL`) — برای انتخاب و اتصال به نماینده |
| `city_id` | integer | ❌ | فیلتر بر اساس شهرِ آدرسِ خودِ پزشک (`doctor_addresses.city_id`) |
| `state_id` | integer | ❌ | فیلتر بر اساس استانِ آدرسِ خودِ پزشک (`doctor_addresses.province_id`) |
| `sort` | string | ❌ | Sort field |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "title": "علی احمدی",
      "degree": "متخصص",
      "gender": "male",
      "doctor_rate": 4.5,
      "active_doctor_appointment": true,
      "representation_id": 12,
      "representation_uuid": "9c1...",
      "representation_name": "علی محمدی"
    }
  ],
  "meta": { "totalRecords": 92, "totalPages": 5, "currentPage": 1 }
}
```

> `representation_id`/`representation_uuid`/`representation_name` نماینده‌ی مالکِ پزشک‌اند؛ برای پزشکانِ بدون نماینده (مثل ایمپورت‌های IRIMC) هر سه `null`.

---

### GET `/api/v1/admin/doctors/stats`

Get doctor statistics.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "total": 92,
    "active": 85,
    "inactive": 7,
    "male": 60,
    "female": 32,
    "top_specialty": "قلب و عروق"
  }
}
```

---

### POST `/api/v1/admin/doctors/{uuid}/status`

Toggle doctor active status.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{ "success": true, "data": { "active": false } }
```

---

### PUT `/api/v1/admin/doctors/{uuid}/representation`

ست کردن یا حذف نماینده‌ی یک پزشک (از صفحه‌ی پروفایل پزشک در پنل ادمین).

**Permission:** `ROLE_ADMIN`

### Request Body
```json
{ "representation_id": 12 }
```
> `representation_id: null` (یا حذف‌شده/خالی) → نماینده‌ی پزشک حذف می‌شود (`representation_id = NULL`). بر خلاف `POST /representations/{uuid}/doctors`، این endpoint اجازه‌ی **تغییر** نماینده‌ی پزشکی که از قبل نماینده دارد را هم می‌دهد.

### Response `200`
```json
{
  "success": true,
  "data": {
    "message": "نماینده ثبت شد",
    "representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
  }
}
```
> برای حذف، `representation` برابر `null` برمی‌گردد.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `DOCTOR_NOT_FOUND` | 404 | پزشک یافت نشد |
| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد |

---

## Clinic Management

### GET `/api/v1/admin/clinics`

List all clinics with pagination.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search by clinic name |
| `status` | string | ❌ | `"active"` or `"inactive"` |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "name": "کلینیک الوند",
      "city": "تهران",
      "telephone": "02112345678",
      "is_active": true,
      "created_at": 1717000000
    }
  ],
  "meta": { "totalRecords": 34, "totalPages": 2, "currentPage": 1 }
}
```

---

### PATCH `/api/v1/admin/clinic/{uuid}/status`

Toggle clinic active/inactive.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{ "success": true, "data": { "is_active": false } }
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |

---

### DELETE `/api/v1/admin/clinic/{uuid}`

Delete a clinic.

**Permission:** `ROLE_ADMIN`

> **Side effect:** the clinic's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — polymorphic `entity_id`, cleaned up at the application level.

### Response `200`
```json
{ "success": true, "data": { "message": "کلینیک حذف شد" } }
```

---

## Appointment Management

### GET `/api/v1/admin/appointments/today-stats`

Get appointment statistics for a specific date (defaults to today).

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `date` | string (YYYY-MM-DD) | ❌ | Default: today |

### Response `200`
```json
{
  "success": true,
  "data": {
    "total": 47,
    "completed": 20,
    "waiting": 18,
    "cancelled": 9
  }
}
```

---

### GET `/api/v1/admin/appointments`

List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 15, max: 500 |
| `search` | string | ❌ | Search by patient name/mobile or doctor name |
| `status` | string | ❌ | Filter by status |
| `date` | string (YYYY-MM-DD) | ❌ | Filter by slot date |
| `doctor_uuid` | string | ❌ | Filter by doctor UUID |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "appt-uuid",
      "patient_name": "محمد رضایی",
      "patient_mobile": "09123456789",
      "doctor_uuid": "doctor-uuid",
      "doctor_name": "علی احمدی",
      "slot_start": 1718438400,
      "slot_end": 1718439600,
      "appointment_date": "2025-06-15",
      "appointment_time": "09:00",
      "end_time": "09:20",
      "status": "confirmed",
      "version": 1,
      "created_at": "2025-06-14T10:30:00+03:30"
    }
  ],
  "meta": { "totalRecords": 47, "totalPages": 1, "currentPage": 1 }
}
```

**Status values:** `pending` | `confirmed` | `completed` | `cancelled_by_doctor` | `cancelled_by_user` | `no_show` | `expired`

---

### POST `/api/v1/admin/appointment`

Create a new appointment for a patient. If no user exists with the given mobile, a new user account is created automatically.

**Permission:** `ROLE_ADMIN`

### Request Body
```json
{
  "doctor_uuid": "doctor-uuid",
  "slot_start": 1718438400,
  "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` و `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
{
  "success": true,
  "data": {
    "uuid": "appt-uuid",
    "slot_start": 1718438400,
    "slot_end": 1718439600,
    "status": "pending"
  }
}
```

### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
| `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 |

---

## Payment Management

### GET `/api/v1/admin/payments`

List all payments.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `status` | string | ❌ | `"pending"`, `"paid"`, `"failed"`, `"cancelled"` |

### Query Parameters (تکمیل)
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `search` | string | ❌ | جستجو در موبایل کاربر، `reference_id`، `order_id` یا دامنهٔ مبدأ |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "amount": 500000,
      "status": "success",
      "gateway": "mellat",
      "ref_id": "1234567",
      "origin": "nobat724.com",
      "patient_mobile": "0912...",
      "paid_at": "2026-07-02T09:00:00+03:30",
      "created_at": "2026-07-02T08:55:00+03:30"
    }
  ],
  "meta": { "totalRecords": 7800, "totalPages": 390, "currentPage": 1 }
}
```

> `amount` بر حسب ریال، `ref_id` همان `reference_id` درگاه، `paid_at` فقط برای پرداخت `success` (بر اساس `updated_at`) و در غیر این‌صورت `null`. تاریخ‌ها ISO-8601.

> `origin` دامنهٔ مبدأ پرداخت است — از `frontend_address` (آدرس بازگشت) استخراج
> می‌شود، با حروف کوچک و بدون `www.`، تا یک دامنه در گزارش یک مقدار باشد. چند مبدأ
> به یک درگاه می‌روند: سایت‌های نوبت‌دهی شهری و پنل خودِ کلینیک‌پرو. پرداخت بدون آدرس
> بازگشت `null` می‌گیرد.

---

### GET `/api/v1/admin/payments/{uuid}`

جزئیات یک پرداخت. **پاسخ تخت است** (`data` مستقیم آبجکت پرداخت، نه nested).

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": {
    "uuid": "...",
    "order_id": "ORD-XXXX",
    "amount": 500000,
    "status": "success",
    "gateway": "mellat",
    "type": "appointment",
    "ref_id": "1234567",
    "origin": "nobat724.com",
    "frontend_address": "https://nobat724.com/payment/result",
    "card_pan": "502229******2928",
    "patient_mobile": "0912...",
    "patient_name": "علی احمدی",
    "appointment_uuid": "...",
    "paid_at": "2026-07-02T09:00:00+03:30",
    "created_at": "2026-07-02T08:55:00+03:30"
  }
}
```

> `card_pan` شمارهٔ کارت ماسک‌شدهٔ پرداخت‌کننده (۶ رقم اول + ۴ رقم آخر) است که درگاه در callback برمی‌گرداند (ملت: `CardHolderPan`) و در `metadata.card_pan` پرداخت ذخیره می‌شود؛ اگر درگاه آن را نفرستد `null`. `refunds[]` تاریخچهٔ استردادها (`amount` ریال، `ref` شماره پیگیری، `at` unix).

### POST `/api/v1/admin/payments/{uuid}/refund`

استرداد وجه یک پرداخت **موفق** (کل یا جزئی). فقط `ROLE_ADMIN`. فقط درگاه ملت پشتیبانی می‌شود (سپ خطا می‌دهد).

**Request body:**
| فیلد | نوع | توضیح |
|------|-----|-------|
| `amount` | integer? | مبلغ استرداد به **ریال**. اگر ندهی = کل باقی‌ماندهٔ قابل استرداد. |

استرداد جزئی چندباره مجاز است تا سقف مبلغ خرید. استرداد کامل (رسیدن جمع به مبلغ کل) وضعیت را `refunded` می‌کند.

**Response 200:**
```json
{ "success": true, "data": { "status": "refunded", "refund_ref": "183800538958" } }
```

> کد `0` درگاه ملت فقط «پذیرش اولیهٔ درخواست استرداد» است؛ عودت نهایی به کارت ممکن است چند روز طول بکشد.

### POST `/api/v1/admin/payments/{uuid}/reverse`

برگشت وجه یک پرداخت **موفقِ settle‌نشده** (بدون body). فقط `ROLE_ADMIN`. در موفقیت وضعیت `refunded`.

**Response 200:** `{ "success": true, "data": { "status": "refunded" } }`

### Errors (payment refund/reverse/detail)
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | پرداخت یافت نشد |
| `ERR_PAYMENT_002` | 422 | مبلغ نامعتبر / پرداخت غیرقابل استرداد / درگاه پشتیبانی نمی‌کند |

---

## Settlement Management

### GET `/api/v1/admin/settlements`

List all settlement requests.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `status` | string | ❌ | `"pending"`, `"approved"`, `"rejected"` |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "user": { "uuid": "...", "real_name": "علی احمدی" },
      "amount_rials": 1000000,
      "status": "pending",
      "bank_account": { "bank_name": "بانک ملت", "owner_name": "..." },
      "created_at": 1717000000
    }
  ],
  "meta": { "totalRecords": 45, "totalPages": 3, "currentPage": 1 }
}
```

> To approve or reject, use the Settlement API: `POST /api/v1/settlement/{uuid}/approve` or `/reject`

---

## Representation Management

### GET `/api/v1/admin/representations`

List all representations.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 15 |
| `search` | string | ❌ | Search by name or mobile (representation's or linked user's) |
| `city_id` | integer | ❌ | Filter by city (عضویت در شهرهای چندگانه‌ی نماینده — `representation_cities`) |

### Response `200`
Paginated representation list. Each item:
```json
{
  "id": 3,
  "uuid": "...",
  "full_name": "حامد حسینی",
  "mobile_number": "09120671756",
  "domain": "x-nobat.ir",
  "is_global": true,
  "city_id": 132,
  "city_ids": [132, 108],
  "cities": [{ "id": 132, "name": "یزد" }, { "id": 108, "name": "تهران" }],
  "city": "یزد، تهران",
  "commission_percent": 10.0,
  "wallet_balance": 0,
  "doctor_count": 14,
  "appointment_count": 231,
  "is_active": true,
  "created_at": "2026-06-18T..."
}
```
> `city_id` = اولین شهر (BC)؛ `city` = نام شهرها با «،». `is_global=true` یعنی نماینده سراسری (badge در پنل).

> `doctor_count` = تعداد پزشکانِ `representation_id = r.id`؛ `appointment_count` = تعداد نوبت‌های آن پزشکان. هر دو با کوئری گروهی محاسبه می‌شوند (بدون N+1).

> `mobile_number` falls back to the linked user's mobile when the representation's own `mobile_number` column is empty.

> **Deactivation, not deletion:** the admin panel deactivates a representation via `PATCH /api/v1/representation/{uuid}` with `{ "active": false }` rather than calling `DELETE`.

---

### GET `/api/v1/admin/representations/{uuid}/doctors`

پزشکان زیرمجموعه‌ی یک نماینده (paginated).

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 15 (max 100) |

### Response `200`
Paginated. Each item:
```json
{
  "uuid": "...",
  "id": 45,
  "name": "علی احمدی",
  "gender": "man",
  "medical_code": "12345",
  "is_active": true,
  "owner_status": "claimed",
  "past_count": 12,
  "upcoming_count": 5,
  "created_at": "2026-06-18T..."
}
```
> `past_count` = نوبت‌های `slot_start < now`؛ `upcoming_count` = `slot_start >= now`.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد |

---

### POST `/api/v1/admin/representations/{uuid}/doctors`

اتصال یک پزشکِ موجودِ **بدون نماینده** به این نماینده (`representation_id` ست می‌شود).

**Permission:** `ROLE_ADMIN`

### Request Body
```json
{ "doctor_uuid": "550e8400-..." }
```

### Response `200`
```json
{ "success": true, "data": { "message": "پزشک به نماینده متصل شد", "doctor_uuid": "550e8400-...", "representation_id": 12 } }
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | `doctor_uuid` ارسال نشده |
| `ERR_NOT_FOUND_001` | 404 | نماینده یا پزشک یافت نشد |
| `ERR_CONFLICT_001` | 409 | پزشک از قبل به یک نماینده متصل است |

---

### GET `/api/v1/admin/representations/{uuid}/appointments`

نوبت‌های پزشکانِ زیرمجموعه‌ی یک نماینده (paginated).

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `scope` | string | ❌ | `upcoming` (پیش‌فرض، `slot_start >= now`، صعودی) یا `past` (`slot_start < now`، نزولی) |
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 15 (max 100) |

### Response `200`
Paginated. Each item:
```json
{
  "uuid": "...",
  "slot_start": 1750000000,
  "slot_end": 1750001800,
  "status": "confirmed",
  "patient_name": "علی رضایی",
  "doctor_uuid": "...",
  "doctor_name": "علی احمدی"
}
```
> `slot_start`/`slot_end` Unix timestamp (ثانیه).

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد |

---

## Secretary Management

### GET `/api/v1/admin/secretaries`

List all secretaries.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search by mobile |

### Response `200`
Paginated secretary list with linked doctor info. هر ردیف علاوه بر مجوزها،
`online_share_enabled` و `online_share_percent` (سهم منشی از نوبت‌های آنلاین) را هم دارد.

---

### PATCH `/api/v1/admin/doctors/{uuid}/mobile` · PATCH `/api/v1/admin/clinic/{uuid}/mobile`

تغییر شمارهٔ **ورود** حساب پزشک یا کلینیک توسط مدیر کل.

**Permission:** `ROLE_ADMIN`

#### Request Body (`application/json`)
```json
{ "mobile_number": "09123456789" }
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mobile_number` | string | ✅ | `09` + ۱۱ رقم؛ ارقام فارسی/عربی هم پذیرفته و نرمال‌سازی می‌شوند |

#### Response `200`
```json
{ "success": true, "data": { "data": { "mobile_number": "09123456789", "previous_mobile_number": "09120000000" } } }
```

- شماره **هویت ورود** کاربر است: بعد از تغییر، ورود فقط با شمارهٔ جدید ممکن است.
- برای پزشک، شمارهٔ نمایشیِ پروفایل (`Doctor.mobile_number`) هم اگر با شمارهٔ ورود یکی بوده (یا خالی است) با آن هم‌گام می‌شود تا دو مقدار واگرا نشوند.

#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_DOCTOR_NOT_FOUND` / `ERR_NOT_FOUND_001` | 404 | پزشک/کلینیک یافت نشد |
| `ERR_CONFLICT_001` | 409 | این شماره قبلاً برای کاربر دیگری ثبت شده (`field: mobile_number`) |
| `ERR_VALIDATION_001` | 422 | قالب شماره نامعتبر (`field: mobile_number`) |

---

### GET `/api/v1/admin/secretary/{uuid}`

جزئیات یک **رابطهٔ** منشی–پزشک/کلینیک (`uuid` = `DoctorSecretary.uuid`، همان uuid لیست بالا) به‌همراه تنظیمات سهم و خلاصهٔ درآمد.

**Permission:** `ROLE_ADMIN`

#### Response `200`
```json
{
  "success": true,
  "data": {
    "data": {
      "uuid": "rel-uuid-…",
      "secretary_uuid": "user-uuid-…",
      "user_name": "زهرا رضایی",
      "mobile_number": "0912…",
      "doctor_name": "دکتر احمدی",
      "doctor_uuid": "doc-uuid-…",
      "owner_type": "doctor",
      "clinic_uuid": null,
      "clinic_name": null,
      "is_active": true,
      "online_share_enabled": true,
      "online_share_percent": 5,
      "permissions": { "…": {} },
      "created_at": 1700000000,
      "earnings": {
        "total_rials": 4500000,
        "this_month_rials": 1500000,
        "appointments_count": 9
      }
    }
  }
}
```

`earnings` روی **کاربرِ منشی** جمع می‌شود (نه فقط این رابطه): مجموع همهٔ سهم‌های ثبت‌شده در `secretary_earnings`. `this_month_rials` = ۳۰ روز گذشته.

#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | منشی یافت نشد |

---

### PUT `/api/v1/admin/secretary/{uuid}/online-share`

فعال/غیرفعال‌کردن محاسبهٔ درآمد منشی از نوبت‌های آنلاین و تعیین درصد سهم. تنظیم
**per-relation** است: یک منشی می‌تواند برای یک پزشک سهم داشته باشد و برای دیگری نه.

**Permission:** `ROLE_ADMIN`

#### Request Body (`application/json`)
```json
{ "enabled": true, "percent": 5 }
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `enabled` | boolean | ✅ | محاسبهٔ سهم برای این رابطه فعال باشد؟ |
| `percent` | number | ✅ | درصد سهم از **مبلغ خالص** نوبت (۰ تا ۱۰۰) |

#### Response `200`
همان شکل رابطه (`DoctorSecretary::toArray()`) پس از ذخیره.

#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | منشی یافت نشد |
| `ERR_VALIDATION_001` | 422 | `percent` خارج از ۰–۱۰۰ (`field: percent`) |
| `ERR_VALIDATION_001` | 422 | `enabled=true` با `percent=0` (`field: percent`) |

> **مبنای محاسبه:** سهم منشی مثل پورسانت نماینده از «خالصِ پس از مالیات» گرفته می‌شود — ابتدا هزینهٔ پنل پیامک، بعد مالیات، بعد سهم‌ها. تنها نوبت‌هایی که **آنلاین** پرداخت می‌شوند سهم می‌سازند (نوبت ثبت‌شده در پنل از مسیر تقسیم مالی عبور نمی‌کند). جزئیات: [settlement.md](settlement.md) و [secretary.md](secretary.md).

---

## Rating & Comment Management

### GET `/api/v1/admin/rates`

List all ratings.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search by doctor/patient |

> Ratings are multi-dimensional (five 0–100 dimensions). Each row's `overall` is the mean of the five dimensions (`0–100`) and `score` is that mean on a 0–5 scale (`overall / 20`).

---

### GET `/api/v1/admin/comments`

List all comments (all statuses).

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 20 |
| `search` | string | ❌ | Search in body |
| `status` | string | ❌ | `"pending"`, `"approved"`, `"rejected"` |

> To approve/reject comments, use the Rating API: `POST /api/v1/admin/comment/{uuid}/approve` or `/reject`

---

## SMS Management (Admin)

### GET `/api/v1/admin/sms/logs`

List SMS send logs.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 15, max 100 |
| `tag` | string | ❌ | فیلتر بر اساس تگ: `global` \| `otp` \| `payment` \| `clinic_invitation` \| `pre_registration` \| `notification_mobile` \| `user_template` |

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "recipient": "09123456789",
      "message": "کد تأیید: 123456",
      "status": "sent",
      "provider": "kavenegar",
      "tag": "otp",
      "template": "clinicpro-otp",
      "sent_at": "2026-06-19T...",
      "created_at": "2026-06-19T..."
    }
  ],
  "meta": { "totalRecords": 5000, "totalPages": 334, "currentPage": 1 }
}
```
> `tag` نوع پیامک را مشخص می‌کند؛ پیش‌فرض پیامک‌های سیستمیِ بی‌برچسب `global` است.
> `template` نام الگوی VerifyLookup کاوه‌نگار است که پیامک با آن ارسال شده (مثلاً `clinicpro-otp`)؛ `null` اگر ارسال نشده باشد (مثلاً رد به‌دلیل نبود الگو در سیاست lookup-only).

---

### GET `/api/v1/admin/sms/templates`

List all SMS templates.

**Permission:** `ROLE_ADMIN`

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "name": "تأیید نوبت",
      "status": "approved",
      "provider_code": "verify_appointment",
      "created_at": 1717000000
    }
  ]
}
```

> To create/approve/reject templates, see [sms.md](sms.md)

---

## Clinic Invitation Management

> See [clinic-invitation.md](clinic-invitation.md) for full endpoint details.

| Endpoint | Description |
|----------|-------------|
| `POST /api/v1/admin/clinic/{uuid}/invite-doctor` | Send invitation |
| `GET /api/v1/admin/clinic/{uuid}/invitations` | List invitations |
| `POST /api/v1/admin/clinic/invitation/{invUuid}/resend` | Resend SMS |
| `PATCH /api/v1/admin/clinic/invitation/{invUuid}/status` | Change status |
| `DELETE /api/v1/admin/clinic/invitation/{invUuid}` | Delete |

---

## Settings

### GET /api/v1/admin/settings

Returns all site configuration values.

**Response `200`**
```json
{
  "success": true,
  "data": {
    "commission_enabled": "0",
    "commission_percent": "0",
    "site_name": "ClinicPro",
    "support_phone": "",
    "max_cancel_hours_before": "24",
    "appointment_reminder_hours": "2",
    "log_retention_days": "90",
    "payment_test_mode": "0",
    "mellat_enabled": "1",
    "mellat_terminal_id": "",
    "mellat_username": "",
    "mellat_password": "",
    "sep_enabled": "1",
    "sep_terminal_id": "",
    "sms_provider": "kavenegar",
    "kavenegar_api_key": "",
    "kavenegar_sender": "",
    "sms_price_rials": "500"
  }
}
```

All values are strings. Missing keys return their default values.

### PATCH /api/v1/admin/settings

Update one or more settings. Unknown keys are silently ignored.

**Request body** (partial update — send only keys to change):
```json
{
  "commission_enabled": "1",
  "commission_percent": "5",
  "site_name": "کلینیک‌پرو",
  "payment_test_mode": "1",
  "mellat_terminal_id": "12345678",
  "mellat_username": "user",
  "mellat_password": "pass",
  "sep_terminal_id": "87654321",
  "sms_provider": "kavenegar",
  "kavenegar_api_key": "your-api-key",
  "kavenegar_sender": "10008664",
  "sms_price_rials": "500"
}
```

**Response `200`** — same shape as GET, returns all settings after save.

**Commission rules:**
- `commission_enabled` — `"1"` = active, `"0"` = inactive
- `commission_percent` — integer string, `0`–`100`
- Commission applies only to regular users (`booked_by = user`); secretaries are exempt

**Payment gateway rules:**
- `payment_test_mode` — `"1"` = all payments use MockGateway (no real bank calls), `"0"` = real gateways
- `mellat_enabled` / `sep_enabled` — `"1"` = gateway selectable, `"0"` = gateway disabled (hidden from the payment selection list and rejected at `initiate`). Unset = enabled (default).
- `payment_allowed_frontend_hosts` — comma-separated hosts allowed as a payment `frontend_address` (origin site to return to). Falls back to the `ALLOWED_FRONTEND_HOSTS` env var when empty. Add a consumer site's host here to permit its payments.
- Gateway credentials (mellat/sep) read from DB first, fallback to env vars if DB value is empty
- MockGateway callback: same URL pattern + `&mock=1&ResCode=0&RefId=MOCK-{orderId}` (add `&cancel=1` to simulate a user cancellation → `canceled`)

**SMS provider rules:**
- `sms_provider` — `"kavenegar"` or `"rangineh"`
- Kavenegar API key and sender read from DB first, fallback to env vars `KAVENEGAR_API_KEY`, `KAVENEGAR_SENDER`

---

## Maintenance Mode

A single switch that takes the **whole platform** offline — the public site, the admin SPA's data calls, every `/api/v1/*` endpoint, and therefore `nobat724_front` and `clinic-pro-tauri` too. Enforced centrally by `App\Shared\EventSubscriber\MaintenanceSubscriber`; no controller checks it itself.

### Settings keys

Managed through the same `GET`/`PATCH /api/v1/admin/settings` endpoints (whitelisted in `SiteConfigController::ALLOWED_KEYS`). Admin panel: `/admin/settings` → بخش «حالت تعمیرات».

| Key | Default | Description |
|---|---|---|
| `maintenance_enabled` | `"0"` | `"1"`/`"true"`/`"on"`/`"yes"` = maintenance active |
| `maintenance_title` | `در حال به‌روزرسانی سیستم` | Heading of the HTML maintenance page |
| `maintenance_message` | `سامانه موقتاً ...` | Shown both on the HTML page and as the API error `message` |
| `maintenance_retry_after` | `"600"` | Seconds; sent as the `Retry-After` response header |
| `maintenance_allowed_ips` | `""` | Comma-separated IPs that bypass maintenance without logging in |

Changing any `maintenance_*` key invalidates the 30-second `MaintenanceService` cache immediately, so a toggle takes effect on the next request.

### Behaviour while enabled

**API requests** (path starts with `/api/`, or `Accept: application/json`, or `X-Requested-With: XMLHttpRequest`):

```
HTTP/1.1 503 Service Unavailable
Retry-After: 600

{
  "success": false,
  "data": null,
  "errors": [
    { "code": "MAINTENANCE_MODE", "message": "<maintenance_message>" }
  ]
}
```

The envelope is identical to `BaseController::error()`, so existing clients parse it unchanged. Clients should detect maintenance by **both** `status === 503` **and** `errors[0].code === "MAINTENANCE_MODE"` — a bare 503 may come from a reverse proxy.

**Browser requests** — `templates/maintenance.html.twig` rendered with HTTP `503`, same `Retry-After` header, `noindex, nofollow`.

Both the `kernel.request` (priority 6) and `kernel.exception` (priority 20) paths are covered, so routing 404/405 and firewall 401 responses also return maintenance rather than leaking their normal errors.

### Who gets through

1. **Whitelisted paths** — never blocked, in this order of importance:
   `/oauth/*`, `/api/v1/user/{login,send-code,verify-code,otp-login}`, `/session/token` (admins must still be able to sign in), `/api/v1/admin/settings` (the only way to turn maintenance back off), `/health`, `/admin*` (the SPA shell HTML — its data calls are still guarded), `/build/*`, `/favicon.ico`, `/_wdt`, `/_profiler`.
2. **`maintenance_allowed_ips`** — exact client-IP match.
3. **`ROLE_ADMIN`** — resolved from the firewall token. Several admin-panel endpoints (`/api/v1/doctors`, `/api/v1/categorys/*`, …) live in the `public_endpoints` firewall with `security: false`, where no token is ever resolved; for those the subscriber falls back to decoding the `Authorization: Bearer` JWT and checking its `roles` claim. An invalid or forged token does not bypass.

Every other role — `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_REPRESENTATION` — is blocked.

### Console escape hatch

If the admin panel is unreachable:

```bash
ddev exec php bin/console app:maintenance status
ddev exec php bin/console app:maintenance on
ddev exec php bin/console app:maintenance off
```

### Failure behaviour

`MaintenanceService` is **fail-open**: if Redis is unavailable it reads straight from the database, and if the database also fails it reports maintenance as disabled. This layer must never become the cause of an outage.

---

## Pre-Registration Management

### GET `/api/v1/admin/pre-registrations`

List pre-registration requests. **Permission:** `ROLE_ADMIN`

**Query params:**

| Param | Default | Notes |
|-------|---------|-------|
| `page` | 1 | |
| `limit` | 20 | max 50 |
| `status` | `pending` | `pending` \| `approved` \| `rejected` \| `all` |

**Response `200`** (paginated):
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "type": "independent_doctor",
      "name": "احمدی",
      "mobile": "09121234567",
      "info": "متخصص داخلی",
      "status": "pending",
      "admin_note": null,
      "created_at": 1718000000
    }
  ],
  "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
}
```

---

### POST `/api/v1/admin/pre-registrations/{uuid}/approve`

Approve a pending request. Creates User + Doctor/Clinic entity based on `type`, resets password, sends SMS. **Permission:** `ROLE_ADMIN`

**Response `200`:**
```json
{ "success": true, "data": { "message": "تأیید شد و اطلاعات ورود ارسال گردید" } }
```

**Error Codes:**

| Code | HTTP | Meaning |
|------|------|---------|
| `NOT_FOUND` | 404 | UUID not found |
| `ALREADY_PROCESSED` | 409 | Status is not pending |

---

### POST `/api/v1/admin/pre-registrations/{uuid}/reject`

Reject a pending request. **Permission:** `ROLE_ADMIN`

**Request body** (optional):
```json
{ "note": "مدارک ناقص است" }
```

**Response `200`:**
```json
{ "success": true, "data": { "message": "درخواست رد شد" } }
```

---

## موتور مالی نمایندگی

تنظیمات مالی از طریق `GET`/`PATCH /api/v1/admin/settings` کنترل می‌شوند (کلیدها در whitelist `SiteConfigController::ALLOWED_KEYS`):

| کلید | پیش‌فرض | شرح |
|------|---------|-----|
| `appointment_commission_enabled` | `0` | فعال‌سازی پورسانت نوبت (درصد از `Representation.commission_percent` هر نماینده) |
| `upgrade_commission_enabled` | `0` | فعال‌سازی پورسانت ارتقاء اشتراک |
| `upgrade_commission_percent` | `20` | درصد پورسانت ارتقاء (سراسری) |
| `tax_enabled` | `0` | فعال‌سازی مالیات بر ارزش افزوده |
| `tax_percent` | `10` | درصد مالیات |
| `sms_panel_fee_rials` | `1500000` | هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر می‌شود) |
| `sms_price_rials` | `500` | هزینه هر پیامک ارسالی به ریال؛ مبنای محاسبهٔ تعداد پیامک از موجودی کیف‌پول (`GET /api/v1/sms/wallet/balance`). قابل ویرایش در `/admin/settings` → بخش پیامک |
| `appointment_fee_rials` | `150000` | مبلغ هر نوبت به ریال؛ مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند. backend از همین کلید می‌خواند و در `GET /api/v1/payment/config` expose می‌شود |
| `log_retention_days` | `90` | مدت نگهداری لاگ‌ها (روز)؛ کاماند روزانه `app:prune-logs` لاگ‌های قدیمی‌تر را حذف می‌کند. `0` = نگهداری نامحدود |
| `altcha_enabled` | _(از env)_ | فعال/غیرفعال کپچای ALTCHA (`1`/`0`). اگر ست شود بر `ALTCHA_ENABLED` env مقدم است؛ اگر ست نشده باشد `GET` مقدار مؤثرِ env را برمی‌گرداند. رجوع به [captcha.md](captcha.md) |

**ترتیب محاسبه** (در `CommissionService`): ۱) کسر `sms_panel_fee_rials` ۲) مالیاتِ استخراجی `afterSms × tax/(100+tax)` ۳) پورسانت = `netAfterTax × percent/100`. سهم نماینده به کیف‌پولش (`WalletTransaction` credit) واریز و یک ردیف `FinancialBreakdown` ثبت می‌شود (idempotent بر اساس `payment_id`).

> هر تغییر `tax_percent`/`tax_enabled` در یک ردیف `TaxRateHistory` ثبت و از `GET /api/v1/admin/settings/tax-history` قابل مشاهده است.

### GET `/api/v1/admin/financial-breakdowns`

لیست تفکیک مالی تراکنش‌ها (paginated). **Permission:** `ROLE_ADMIN`

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `page` | integer | پیش‌فرض 1 |
| `limit` | integer | پیش‌فرض 15، حداکثر 100 |
| `representation_id` | integer | فیلتر نماینده |
| `source` | string | `appointment` یا `subscription` |
| `from` | integer | Unix timestamp شروع بازه |
| `to` | integer | Unix timestamp پایان بازه |

**Response `200` (paginated):**
```json
{
  "success": true,
  "data": [
    {
      "uuid": "…", "order_id": "ORD-…", "source": "appointment",
      "gross_rials": 2000000, "sms_fee_rials": 1500000,
      "tax_percent": 10, "tax_rials": 45455, "net_after_tax_rials": 454545,
      "commission_percent": 20, "representation_share_rials": 90909,
      "system_share_rials": 363636,
      "representation_id": 3, "representation_name": "نماینده یزد",
      "doctor_id": 12, "clinic_id": null, "created_at": "2026-06-24T…"
    }
  ],
  "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
```

### GET `/api/v1/admin/financial-summary`

جمع کل مبالغ. **Permission:** `ROLE_ADMIN`

**Response `200`:**
```json
{
  "success": true,
  "data": {
    "total_gross": 2000000,
    "total_representation_income": 90909,
    "total_tax_collected": 45455,
    "total_sms_fee": 1500000,
    "total_system_share": 363636
  }
}
```

### GET `/api/v1/admin/settings/tax-history`

تاریخچه‌ی تغییرات مالیات بر ارزش افزوده (۵۰ ردیف آخر، نزولی). هر بار که `tax_percent` یا `tax_enabled` از طریق `PATCH /api/v1/admin/settings` تغییر کند، یک ردیف با کاربرِ تغییردهنده ثبت می‌شود. **Permission:** `ROLE_ADMIN`

**Response `200`:**
```json
{
  "success": true,
  "data": [
    { "tax_percent": 10, "enabled": true, "changed_by_name": "مدیر سیستم", "changed_at": 1782800000 }
  ]
}
```

### GET `/api/v1/admin/settlement/{uuid}`

جزئیات یک درخواست تسویه (برای صفحه‌ی `/admin/settlements/{uuid}`). **Permission:** `ROLE_ADMIN`

**Response `200`:**
```json
{
  "success": true,
  "data": {
    "uuid": "...",
    "representation_name": "نماینده یزد",
    "representation_mobile": "09390036732",
    "amount": 500000,
    "status": "pending",
    "bank_card": "6037...", "bank_name": "ملت", "bank_iban": "IR...", "bank_owner": "...",
    "reject_reason": null,
    "requested_at": "2026-06-24T...", "processed_at": null
  }
}
```
تأیید/رد از طریق `POST /api/v1/settlement/{uuid}/approve|reject` (در `docs/api/settlement.md`).

**Errors:** `NOT_FOUND` (404) — درخواست یافت نشد.

---

## Application Logs

Persisted application logs (`warning` level and above). Written by the `DbLogger`
decorator over the `logger` service into the `app_log` table — every
`LoggerInterface::warning()/error()/critical()/...` call across the backend lands
here, while `info`/`debug` go to stderr only.

### GET `/api/v1/admin/logs`

List persisted logs with pagination and filters.

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `page` | integer | ❌ | Default: 1 |
| `limit` | integer | ❌ | Default: 25 (max 100) |
| `level` | string | ❌ | Exact PSR level: `warning`, `error`, `critical`, `alert`, `emergency` |
| `search` | string | ❌ | Substring match on the message |
| `from` | integer | ❌ | Unix timestamp lower bound (`created_at >=`) |
| `to` | integer | ❌ | Unix timestamp upper bound (`created_at <=`) |

Ordered by newest first (`id DESC`).

### Response `200`
```json
{
  "success": true,
  "data": [
    {
      "id": 4213,
      "level": "error",
      "message": "Unhandled exception: RuntimeException: boom @ /var/www/html/src/Foo.php:42 [path=/oauth/userinfo]",
      "context": "{\"exception\":\"RuntimeException: boom @ /var/www/html/src/Foo.php:42\"}",
      "channel": "app",
      "path": "/oauth/userinfo",
      "created_at": 1717000000
    }
  ],
  "meta": { "totalRecords": 137, "totalPages": 6, "currentPage": 1 }
}
```

Notes:
- `context` is a JSON string (or `null`); a `Throwable` in the context is stored as a compact `Class: message @ file:line` string, never the raw object.
- `created_at` is a Unix timestamp (integer).

### GET `/api/v1/admin/logs/export`

Export **all** matching logs as a CSV file (no pagination). Respects the same `level`, `search`, `from`, `to` filters as the list endpoint. Ordered newest first (`id DESC`).

**Permission:** `ROLE_ADMIN`

### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `level` | string | ❌ | Exact PSR level: `warning`, `error`, `critical`, `alert`, `emergency` |
| `search` | string | ❌ | Substring match on the message |
| `from` | integer | ❌ | Unix timestamp lower bound (`created_at >=`) |
| `to` | integer | ❌ | Unix timestamp upper bound (`created_at <=`) |

#### Response `200`
- `Content-Type: text/csv; charset=UTF-8`
- `Content-Disposition: attachment; filename="logs-YYYYMMDD-HHMMSS.csv"`
- Streamed CSV with a UTF-8 BOM (Excel-friendly for Persian). Columns: `id, level, message, context, channel, path, created_at`. `created_at` is formatted as `Y-m-d H:i:s`.

### DELETE `/api/v1/admin/logs`

Delete **all** persisted logs (truncate the `app_log` table). Irreversible.

**Permission:** `ROLE_ADMIN`

#### Response `200`
```json
{ "success": true, "data": { "deleted": 137 } }
```

- `deleted` — number of rows removed.

### Log Retention

Logs are pruned automatically based on the `log_retention_days` setting (see [Site Settings](#) — `GET`/`PATCH /api/v1/admin/settings`, whitelisted key `log_retention_days`, default `90`, `0` = keep forever).

- A daily scheduled task (`App\Shared\Logging\Message\PruneLogsMessage`, registered in `src/Schedule.php`, routed to `scheduler_default`) deletes logs older than `log_retention_days`.
- Manual prune: `php bin/console app:prune-logs` (reads the same setting, deletes older-than-retention rows, prints the count).
- Requires the scheduler worker: `php bin/console messenger:consume scheduler_default`.
