- Implemented PATCH endpoints for changing the login mobile number of doctors and clinics. - Added ChangeLoginMobileModal component for handling mobile number updates in the UI. - Updated ClinicsPage and DoctorsPage to include buttons for changing mobile numbers. - Enhanced AdminApiController to manage mobile number changes with validation. - Created tests to ensure proper functionality and validation for mobile number changes. - Updated API documentation to reflect new endpoints and their usage.
1590 lines
51 KiB
Markdown
1590 lines
51 KiB
Markdown
# 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",
|
||
"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. هر ردیف علاوه بر مجوزها،
|
||
`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`.
|