- Created migration to add representation_cities table and domain, is_global fields to representations. - Implemented SiteContextController to resolve domain to site context (city | representation | unknown). - Developed DomainContext and DomainContextResolver services for domain mapping. - Added tests for DomainContextResolver and commission logic based on domain ownership.
23 KiB
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)
{
"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_ids |
integer[] | ❌ | شهرهای تحت پوشش (چند-شهری). city_id تکی هم برای BC پذیرفته میشود |
domain |
string | ❌ | دامنه اختصاصی نماینده (نرمال میشود: بدون scheme/www). یکتا؛ نباید با دامنه شهرها تداخل کند. admin-only |
is_global |
boolean | ❌ | نماینده سراسری — سایتِ دامنهاش فقط پزشکان/کلینیکهای خودش را نشان میدهد. admin-only |
commission_percent |
float | ❌ | Commission rate (0–100) |
bank_account |
object | ❌ | Bank details for settlements |
پاسخها اکنون شامل: city_ids: int[]، cities: [{id, name}]، domain، is_global (علاوه بر city_id قدیمی = اولین شهر).
Response 201
{
"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
{
"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)
{
"full_name": "علی احمدی جدید",
"city_id": 50,
"commission_percent": 12,
"bank_account": { ... },
"active": true
}
All fields optional. city_ids: int[] جایگزین city_id است (تکی هم پذیرفته میشود).
Privileged fields: commission_percent، active، domain و is_global admin-only هستند — نماینده روی رکورد خودش فقط full_name، city_ids، bank_account را میتواند تغییر دهد. commission_percent باید در بازه 0–100 باشد. خطاهای domain: نامعتبر → 422، تکراری یا برخورد با دامنه شهر → 409.
Response 200
Updated representation object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_FORBIDDEN_001 |
403 | Not owner or admin |
ERR_AUTH_006 |
403 | Non-admin tried to change commission_percent or active |
ERR_VALIDATION_001 |
422 | commission_percent خارج از بازه ۰ تا ۱۰۰ (field: commission_percent) |
ERR_NOT_FOUND_001 |
404 | Representation not found |
DELETE /api/v1/representation/{uuid}
Delete a representation.
Permission: ROLE_ADMIN
Response 200
{ "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
{
"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
{
"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
}
}
}
GET /api/v1/site-context
عمومی (بدون auth). نگاشت یک دامنه به زمینهی سایت — مصرفکننده: سایت عمومی nobat724 برای دامنههای خارج از data/city.json (دامنه اختصاصی نمایندگان سراسری).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
domain |
string | ✅ | host یا URL کامل؛ نرمال میشود (scheme/www/پورت حذف) |
Response 200
{
"success": true,
"data": {
"type": "representation",
"city": null,
"representation": { "uuid": "...", "full_name": "نماینده الف", "is_global": true }
}
}
type: city (دامنه یکی از شهرها) | representation (دامنه اختصاصی نماینده فعال) | unknown. برای city، آبجکت city: {id, name} پر میشود.
قانون کمیسیون دامنهمحور
کمیسیون (نوبت و اشتراک) فقط وقتی ثبت میشود که هر دو شرط برقرار باشد:
- دامنهی مبدأ خرید (
payment.frontend_address) متعلق به یک نمایندهی فعال باشد (representations.domain). - پزشک/کلینیکِ موضوع خرید،
representation_idهمان نماینده را داشته باشد.
در غیر این صورت هیچ کمیسیونی برای هیچ نمایندهای ثبت نمیشود (پرداخت بدون frontend_address هم کمیسیون ندارد). درصد: نوبت = commission_percent نماینده؛ اشتراک = تنظیم سراسری upgrade_commission_percent. نگاشت دامنه فقط از طریق DomainContextResolver انجام میشود.
پنل نماینده (ROLE_REPRESENTATION)
این endpointها برای کاربرِ دارای نقش ROLE_REPRESENTATION در پنل ادمین (/admin) هستند. مالکیت همیشه از کاربر جاری (#[CurrentUser] + findByUser) تعیین میشود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمیشود.
Permission (همهی این بخش):
ROLE_REPRESENTATION
GET /api/v1/representation/me
پروفایل نمایندهی کاربر جاری.
Response 200
{
"success": true,
"data": {
"data": {
"uuid": "...",
"full_name": "حامد حسینی",
"mobile_number": "09120671756",
"city_id": 132,
"commission_percent": "10.00",
"bank_account": [
{ "id": "iban-uuid-1", "iban": "IR000000000000000000000000", "bank_name": "بانک ملت", "owner_name": "حامد حسینی", "verified": true, "created_at": 1718000000 }
],
"active": true,
"created_at": 1718000000,
"national_code": "0012345678",
"national_code_verified": true
}
}
}
double-nested: مقدار با
data.dataاستخراج میشود.bank_accountآرایهای از ۰ تا ۲ شبا است (nullاگر هیچ شبایی ثبت نشده).national_code/national_code_verifiedاز کاربرِ نماینده میآید.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | کاربر جاری نماینده نیست |
POST /api/v1/representation/verify-national-code
تأیید کد ملی نماینده با استعلام شاهکار (s.api.ir → ShahkarLite): تطبیق کد ملی با موبایلِ کاربر جاری. در صورت موفقیت، national_code ذخیره و national_code_verified=true میشود.
Request Body
{ "national_code": "0012345678" }
| Field | Type | Required | Description |
|---|---|---|---|
national_code |
string | ✅ | کد ملی ۱۰ رقمی |
Response 200
آبجکت پروفایل نماینده (مثل me، با national_code_verified: true).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | کد ملی ۱۰ رقم نیست (field: national_code) |
ERR_IDENTITY_001 |
422 | کد ملی متعلق به این موبایل نیست (field: national_code) |
ERR_EXTERNAL_001 |
502 | خطا در استعلام |
ERR_EXTERNAL_002 |
503 | سرویس استعلام پیکربندی نشده |
POST /api/v1/representation/iban
افزودن یک شماره شبا. ابتدا با IbanMatch (s.api.ir) بررسی میشود شبا متعلق به کد ملیِ تأییدشدهی نماینده باشد. حداکثر ۲ شبا.
Request Body
{ "iban": "IR000000000000000000000000", "birth_date": "1370/01/01" }
| Field | Type | Required | Description |
|---|---|---|---|
iban |
string | ✅ | شماره شبا (با/بدون IR و فاصله؛ نرمالسازی میشود) |
birth_date |
string | ✅ | تاریخ تولد شمسی Y/m/d (نمونه 1370/01/01). فقط برای استعلام IbanMatch؛ ذخیره نمیشود. |
Response 200
آبجکت پروفایل نماینده با bank_account بهروزشده.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_IDENTITY_004 |
409 | کد ملی هنوز تأیید نشده |
ERR_IDENTITY_003 |
409 | سقف ۲ شبا پر است |
ERR_VALIDATION_001 |
422 | شبا نامعتبر (field: iban) یا تاریخ تولد نامعتبر (field: birth_date) |
ERR_IDENTITY_002 |
422 | شبا متعلق به نماینده نیست (field: iban) |
ERR_EXTERNAL_001 |
502 | خطا در استعلام |
ERR_EXTERNAL_002 |
503 | سرویس استعلام پیکربندی نشده |
DELETE /api/v1/representation/iban/{id}
حذف یک شماره شبا با id آن (از bank_account[].id).
Response 200
آبجکت پروفایل نماینده با bank_account بهروزشده.
POST /api/v1/representation/doctor
افزودن پزشک توسط نماینده. representation_id پزشک بهصورت خودکار روی نمایندهی کاربر جاری ست میشود. پس از ثبت موفق، یک پیامک خوشآمد (تگ welcome) بهصورت async به موبایل پزشک ارسال میشود.
Request Body
{ "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 | ❌ |
activity_time |
integer (Unix ts، تاریخ شروع فعالیت) | ❌ |
specialties |
integer[] | ❌ |
Response 201
{ "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 دیده شود. پس از ثبت موفق، یک پیامک خوشآمد (تگ welcome) بهصورت async به موبایل مالک کلینیک ارسال میشود.
Request Body
{ "owner_mobile": "0935...", "name": "کلینیک ...", "telephone": "...", "address": "..." }
| Field | Type | Required |
|---|---|---|
owner_mobile |
string | ✅ |
name |
string | ✅ |
telephone / address / info |
string | ❌ |
Response 200
{ "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
{
"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
{
"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
{
"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
{ "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
{
"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:
{
"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):
{
"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):
{
"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) همان نماینده محاسبه میشود.