- Added domain guard in CommissionService to ensure commission is calculated only when the appointment is booked under the same representation as the doctor. - Updated RepresentationController to filter statistics by representation, ensuring accurate data is shown for each representative. - Introduced new endpoints for the representation dashboard to provide summary statistics, doctor performance, and financial reports. - Created new pages for RepresentationFinance and RepresentationSettlement to display financial data and allow for settlement requests. - Added migration to include booking_representation_id in appointments for tracking the representative under which the appointment was booked.
547 lines
17 KiB
Markdown
547 lines
17 KiB
Markdown
# Representation (Agent) API
|
||
|
||
> **Prefix:** `/api/v1/representation`
|
||
|
||
Representations are sales agents who earn commission on appointments booked through their referral.
|
||
|
||
---
|
||
|
||
## POST `/api/v1/representation`
|
||
|
||
Create a new representation.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"full_name": "علی احمدی",
|
||
"mobile_number": "09123456789",
|
||
"city_id": 42,
|
||
"commission_percent": 10,
|
||
"bank_account": {
|
||
"iban": "IR...",
|
||
"account_number": "1234567890",
|
||
"bank_name": "بانک ملت",
|
||
"owner_name": "علی احمدی"
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `full_name` | string | ✅ | Agent full name |
|
||
| `mobile_number` | string | ✅ | Login mobile (creates a User account) |
|
||
| `city_id` | integer | ❌ | City ID (FK to categories where bundle=city) |
|
||
| `commission_percent` | float | ❌ | Commission rate (0–100) |
|
||
| `bank_account` | object | ❌ | Bank details for settlements |
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "rep-uuid-...",
|
||
"full_name": "علی احمدی",
|
||
"mobile_number": "09123456789",
|
||
"city_id": 42,
|
||
"commission_percent": 10,
|
||
"active": true,
|
||
"bank_account": { ... },
|
||
"created_at": 1717000000
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_CONFLICT_001` | 409 | Mobile number already in use |
|
||
| `ERR_VALIDATION_001` | 422 | `mobile_number` فرمت معتبر موبایل ایران (`^09\d{9}$`) ندارد (`field: mobile_number`) |
|
||
| `ERR_VALIDATION_002` | 422 | `mobile_number` یا `full_name` خالی |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/representation/{uuid}`
|
||
|
||
Get representation detail.
|
||
|
||
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string (UUID) | Representation UUID |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "...",
|
||
"full_name": "علی احمدی",
|
||
"mobile_number": "09123456789",
|
||
"city_id": 42,
|
||
"city_name": "تهران",
|
||
"commission_percent": 10,
|
||
"active": true,
|
||
"bank_account": { ... },
|
||
"created_at": 1717000000
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not owner or admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Representation not found |
|
||
|
||
---
|
||
|
||
## PATCH `/api/v1/representation/{uuid}`
|
||
|
||
Update representation.
|
||
|
||
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"full_name": "علی احمدی جدید",
|
||
"city_id": 50,
|
||
"commission_percent": 12,
|
||
"bank_account": { ... },
|
||
"active": true
|
||
}
|
||
```
|
||
|
||
All fields optional.
|
||
|
||
### Response `200`
|
||
Updated representation object.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not owner or admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Representation not found |
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/representation/{uuid}`
|
||
|
||
Delete a representation.
|
||
|
||
**Permission:** `ROLE_ADMIN`
|
||
|
||
### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "message": "نماینده حذف شد" } }
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Representation not found |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/representation/{uuid}/dashboard/monthly`
|
||
|
||
Get monthly earnings dashboard for a representation.
|
||
|
||
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string (UUID) | Representation UUID |
|
||
|
||
### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `year` | integer | ✅ | e.g. `2024` |
|
||
| `month` | integer | ✅ | 1–12 |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"period": { "year": 2024, "month": 6 },
|
||
"stats": {
|
||
"total_appointments": 15,
|
||
"total_revenue_rials": 7500000,
|
||
"commission_rials": 750000,
|
||
"daily": [
|
||
{ "date": "2024-06-01", "appointments": 2, "revenue": 1000000, "commission": 100000 }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not owner or admin |
|
||
| `ERR_NOT_FOUND_001` | 404 | Representation not found |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/representation/{uuid}/dashboard/yearly`
|
||
|
||
Get yearly earnings dashboard for a representation.
|
||
|
||
**Permission:** `AUTH` — must be the representation's user or `ROLE_ADMIN`
|
||
|
||
### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `year` | integer | ✅ | e.g. `2024` |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"period": { "year": 2024 },
|
||
"months": [
|
||
{ "month": 1, "appointments": 10, "revenue_rials": 5000000, "commission_rials": 500000 },
|
||
{ "month": 2, "appointments": 8, "revenue_rials": 4000000, "commission_rials": 400000 }
|
||
],
|
||
"totals": {
|
||
"appointments": 97,
|
||
"revenue_rials": 48500000,
|
||
"commission_rials": 4850000
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## پنل نماینده (ROLE_REPRESENTATION)
|
||
|
||
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین میشود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمیشود.
|
||
|
||
> **Permission (همهی این بخش):** `ROLE_REPRESENTATION`
|
||
|
||
### GET `/api/v1/representation/me`
|
||
|
||
پروفایل نمایندهی کاربر جاری.
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"uuid": "...",
|
||
"full_name": "حامد حسینی",
|
||
"mobile_number": "09120671756",
|
||
"city_id": 132,
|
||
"commission_percent": "10.00",
|
||
"bank_account": null,
|
||
"active": true,
|
||
"created_at": 1718000000
|
||
}
|
||
}
|
||
}
|
||
```
|
||
> double-nested: مقدار با `data.data` استخراج میشود.
|
||
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||
|
||
---
|
||
|
||
### POST `/api/v1/representation/doctor`
|
||
|
||
افزودن پزشک توسط نماینده. `representation_id` پزشک بهصورت خودکار روی نمایندهی کاربر جاری ست میشود.
|
||
|
||
#### Request Body
|
||
```json
|
||
{ "mobile": "0935...", "name": "دکتر ...", "gender": "man", "degree": "...", "medical_system_code": "...", "specialties": [1,2] }
|
||
```
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `mobile` | string | ✅ |
|
||
| `name` | string | ✅ |
|
||
| `gender` / `degree` / `medical_system_code` / `info` | string | ❌ |
|
||
| `specialties` | integer[] | ❌ |
|
||
|
||
#### Response `201`
|
||
```json
|
||
{ "success": true, "data": { "uuid": "..." } }
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی |
|
||
| `ERR_VALIDATION_001` | 422 | `mobile` فرمت معتبر موبایل ایران ندارد (`field: mobile`) |
|
||
| `ERR_CONFLICT_001` | 409 | این کاربر قبلاً پزشک است |
|
||
|
||
---
|
||
|
||
### POST `/api/v1/representation/clinic`
|
||
|
||
افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نمایندهی کاربر جاری ست میشود (مثل createDoctor) تا در لیستهای scoped دیده شود.
|
||
|
||
#### Request Body
|
||
```json
|
||
{ "owner_mobile": "0935...", "name": "کلینیک ...", "telephone": "...", "address": "..." }
|
||
```
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `owner_mobile` | string | ✅ |
|
||
| `name` | string | ✅ |
|
||
| `telephone` / `address` / `info` | string | ❌ |
|
||
|
||
#### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "uuid": "...", "name": "...", "is_active": true } }
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی |
|
||
| `ERR_VALIDATION_001` | 422 | `owner_mobile` فرمت معتبر موبایل ایران ندارد (`field: owner_mobile`) |
|
||
|
||
---
|
||
|
||
### GET `/api/v1/representation/appointments`
|
||
|
||
نوبتهای همهی پزشکانی که `representation_id` آنها = نمایندهی کاربر جاری است (paginated، با شکل آیتمِ یکسان با `/api/v1/admin/appointments`).
|
||
|
||
#### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `page` | integer | ❌ | پیشفرض 1 |
|
||
| `limit` | integer | ❌ | پیشفرض 15، حداکثر 500 |
|
||
| `status` | string | ❌ | فیلتر وضعیت |
|
||
| `date` | string (YYYY-MM-DD) | ❌ | فیلتر تاریخِ نوبت |
|
||
| `search` | string | ❌ | جستجو در موبایل/نام بیمار یا نام پزشک |
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"patient_name": "...",
|
||
"patient_mobile": "0912...",
|
||
"doctor_uuid": "...",
|
||
"doctor_name": "دکتر ...",
|
||
"slot_start": 1718000000,
|
||
"slot_end": 1718001800,
|
||
"appointment_date": "2025-06-15",
|
||
"appointment_time": "10:00",
|
||
"end_time": "10:30",
|
||
"status": "confirmed",
|
||
"created_at": 1717900000
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 12, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||
|
||
---
|
||
|
||
### GET `/api/v1/representation/doctors`
|
||
|
||
پزشکانِ ثبتشده توسط نمایندهی جاری (فقط ردیفهای `representation_id = نمایندهی کاربر جاری`). شکل آیتم یکسان با `GET /api/v1/admin/doctors` است.
|
||
|
||
> **Permission:** `ROLE_REPRESENTATION` — id نماینده از `#[CurrentUser]` تعیین میشود، نه از query (نماینده نمیتواند دادهی نمایندهی دیگر را ببیند).
|
||
|
||
#### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `page` | integer | ❌ | پیشفرض 1 |
|
||
| `limit` | integer | ❌ | پیشفرض 15، حداکثر 100 |
|
||
| `search` | string | ❌ | جستجو در نام یا موبایل پزشک |
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...", "id": 12, "name": "دکتر ...", "gender": "man", "degree": "...",
|
||
"medical_code": "...", "mobile": "0912...", "email": null,
|
||
"is_active": true, "rate": 3.5, "specialties": [],
|
||
"profile_image": null, "created_at": "2026-06-18T..."
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||
|
||
---
|
||
|
||
### GET `/api/v1/representation/doctors/stats`
|
||
|
||
آمار پزشکانِ ثبتشده توسط نمایندهی جاری (فقط `representation_id = نمایندهی کاربر جاری`). شکل پاسخ سازگار با `GET /api/v1/admin/doctors/stats` (بدون `top_specialty`). فرانتاند کارتهای «کل پزشکان / فعال / غیرفعال / مرد / زن» را از این endpoint برای نقش نماینده پر میکند.
|
||
|
||
> **Permission:** `ROLE_REPRESENTATION` — id نماینده از `#[CurrentUser]`.
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": { "total": 12, "active": 9, "inactive": 3, "male": 7, "female": 5 }
|
||
}
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||
|
||
---
|
||
|
||
### POST `/api/v1/representation/doctors/{uuid}/status`
|
||
|
||
فعال/غیرفعال کردن پزشکِ زیرمجموعهی نمایندهی جاری (toggle `active_doctor_appointment`). فقط روی پزشکانی که `representation_id` آنها برابر نمایندهی کاربر جاری است؛ در غیر این صورت 404.
|
||
|
||
> **Permission:** `ROLE_REPRESENTATION` — مالکیت از `#[CurrentUser]` چک میشود.
|
||
|
||
#### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string | uuid پزشک |
|
||
|
||
#### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "is_active": false } }
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست، یا پزشک یافت نشد / متعلق به این نماینده نیست |
|
||
|
||
---
|
||
|
||
### GET `/api/v1/representation/clinics`
|
||
|
||
کلینیکهای ثبتشده توسط نمایندهی جاری (فقط `representation_id = نمایندهی کاربر جاری`). شکل آیتم سازگار با `GET /api/v1/admin/clinics`.
|
||
|
||
> **Permission:** `ROLE_REPRESENTATION` — id نماینده از `#[CurrentUser]`.
|
||
|
||
#### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `page` | integer | ❌ | پیشفرض 1 |
|
||
| `limit` | integer | ❌ | پیشفرض 15، حداکثر 100 |
|
||
| `search` | string | ❌ | جستجو در نام یا تلفن کلینیک |
|
||
|
||
#### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...", "id": 5, "name": "کلینیک ...", "telephone": "...",
|
||
"logo": null, "clinic_logo": null, "is_active": true,
|
||
"doctors_count": 0, "created_at": "2026-06-18T..."
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||
|
||
---
|
||
|
||
## داشبورد، عملکرد و مالیِ نمایندهی جاری
|
||
|
||
> همهی این endpointها `#[IsGranted('ROLE_REPRESENTATION')]` و scope بر اساس `#[CurrentUser]` (نه uuid مسیر). درآمد همیشه از `FinancialBreakdown.representation_share_rials` (پورسانت واقعیِ ثبتشده) محاسبه میشود، نه مبلغ کل نوبت. بازهها: امروز=`strtotime('today')`, هفته=۷ روز اخیر, ماه=۳۰ روز اخیر.
|
||
|
||
### GET `/api/v1/representation/dashboard/summary`
|
||
|
||
خلاصهی آمار نوبت و درآمد نمایندهی جاری.
|
||
|
||
**Response `200`:**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"appointments": { "today": 0, "week": 3, "month": 12, "total": 40 },
|
||
"income": {
|
||
"today": 0, "week": 270000, "month": 909090, "total": 3000000,
|
||
"settlable_rials": 2090910, "settled_rials": 500000, "pending_rials": 0
|
||
}
|
||
}
|
||
}
|
||
```
|
||
`settlable_rials` = موجودی کیفپول (`getWalletBalance`)؛ `settled_rials` = جمع Settlementهای `paid`؛ `pending_rials` = جمع `pending`+`approved`.
|
||
|
||
#### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست |
|
||
|
||
### GET `/api/v1/representation/doctors/performance`
|
||
|
||
عملکرد پزشکانِ نمایندهی جاری (paginated). **Query:** `page`, `limit`.
|
||
|
||
**Response `200` (paginated):**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...", "name": "دکتر ...",
|
||
"appointments": { "today": 0, "week": 1, "month": 4, "total": 18 },
|
||
"representation_income_rials": 363636,
|
||
"subscription_status": "active"
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
`subscription_status`: `active` (اشتراک فعال دارد) یا `none`.
|
||
|
||
### GET `/api/v1/representation/finance/report`
|
||
|
||
گزارش مالی بازهای از ردیفهای `FinancialBreakdown` نمایندهی جاری (paginated). **Query:** `page`, `limit`, `from` (Unix ts), `to` (Unix ts).
|
||
|
||
**Response `200` (paginated):**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...", "appointment_uuid": "...", "doctor_name": "دکتر ...",
|
||
"gross_rials": 2000000, "tax_rials": 45455, "sms_fee_rials": 1500000,
|
||
"commission_percent": 20, "representation_share_rials": 90909,
|
||
"created_at": "2026-06-24T..."
|
||
}
|
||
],
|
||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||
}
|
||
```
|
||
|
||
> **اصلاح `buildStats`** (در `GET /api/v1/representation/{uuid}/dashboard/monthly|yearly`): قبلاً آمار را به نماینده فیلتر نمیکرد (کلِ پلتفرم). اکنون `total_appointments` فقط نوبتهای پزشکانِ همان نماینده، `commission_rials` از `FinancialBreakdown.representation_share_rials`، و `total_revenue_rials` از `FinancialBreakdown.gross_rials` (source=appointment) همان نماینده محاسبه میشود.
|