Files
clinicpro/docs/api/admin.md
T
hamedandClaude Opus 4.8 5548d79d4c feat(appointment): identify admin-booked patient by national code
Admin-side booking (POST /api/v1/my/appointment and
/api/v1/admin/appointment) resolved the patient User by mobile only, so
one person booked under two mobiles produced two User rows — and two
case-files, since PatientRecord is keyed on user_id. National code is the
real unique identity (User.national_code is already unique); a person may
have several mobiles.

Booking now requires + validates patient_national_code and resolves the
patient national-code-first (then mobile) via a shared PatientResolver, so
the case-file stays unique per national code even across mobiles. Reusing a
mobile already bound to a different national code returns 422
ERR_PROFILE_MOBILE_TAKEN. The admin create form and NewAppointmentDrawer
gain a national-code field and send it; both had a dead patient-picker URL
(/api/v1/patient) fixed to the real /api/v1/patients, whose payload already
carries user_national_code for autofill.

Docs (appointment.md, admin.md) and tests updated; new
AppointmentNationalCodeTest covers success, single-file reuse, missing,
invalid, and identity-conflict cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:11:40 +03:30

1403 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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",
"note": "optional note"
}
```
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد. بیمار **اول با کد ملی** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر کاربری یافت نشود، کاربر جدید با نقش `ROLE_USER` و همان کد ملی ساخته می‌شود.
### 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`) |
| `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",
"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.
---
### 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",
"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.
---
## 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 0100 dimensions). Each row's `overall` is the mean of the five dimensions (`0100`) and `score` is that mean on a 05 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`
---
## 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`.