feat: add multi-city representation support and domain context resolution
- 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.
This commit is contained in:
+7
-2
@@ -742,7 +742,7 @@ List all representations.
|
||||
| `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 |
|
||||
| `city_id` | integer | ❌ | Filter by city (عضویت در شهرهای چندگانهی نماینده — `representation_cities`) |
|
||||
|
||||
### Response `200`
|
||||
Paginated representation list. Each item:
|
||||
@@ -752,14 +752,19 @@ Paginated representation list. Each item:
|
||||
"uuid": "...",
|
||||
"full_name": "حامد حسینی",
|
||||
"mobile_number": "09120671756",
|
||||
"domain": "x-nobat.ir",
|
||||
"is_global": true,
|
||||
"city_id": 132,
|
||||
"city": "یزد",
|
||||
"city_ids": [132, 108],
|
||||
"cities": [{ "id": 132, "name": "یزد" }, { "id": 108, "name": "تهران" }],
|
||||
"city": "یزد، تهران",
|
||||
"commission_percent": 10.0,
|
||||
"wallet_balance": 0,
|
||||
"is_active": true,
|
||||
"created_at": "2026-06-18T..."
|
||||
}
|
||||
```
|
||||
> `city_id` = اولین شهر (BC)؛ `city` = نام شهرها با «،». `is_global=true` یعنی نماینده سراسری (badge در پنل).
|
||||
|
||||
> `mobile_number` falls back to the linked user's mobile when the representation's own `mobile_number` column is empty.
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ List clinics with pagination.
|
||||
| `city` | integer | ❌ | City id — filters by the **clinic address's** city |
|
||||
| `state` | integer | ❌ | Province id — filters by the **clinic address's** province |
|
||||
| `specialty` | integer | ❌ | Specialty id |
|
||||
| `domain` | string | ❌ | دامنهی سایتِ درخواستکننده. اگر دامنهی یک **نماینده سراسری** باشد، فقط کلینیکهای همان نماینده برمیگردند و `city`/`state` نادیده گرفته میشوند؛ دامنه شهری/ناشناخته اثری ندارد |
|
||||
|
||||
> `city`/`state` are matched against the clinic's address (`DoctorAddress` linked by `clinic_id`), not a field on the clinic itself.
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ List doctors with pagination and filters.
|
||||
| `specialty_id` | integer | ❌ | Filter by specialty ID |
|
||||
| `city_id` | integer | ❌ | Filter by city ID — شامل دکترهایی که آدرس شخصیشان (`doctor_addresses.city_id`, با `doctor_id` مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) |
|
||||
| `state_id` | integer | ❌ | Filter by province ID — بر اساس آدرس شخصی پزشک (`doctor_addresses.province_id`) یا آدرس کلینیک |
|
||||
| `domain` | string | ❌ | دامنهی سایتِ درخواستکننده. اگر دامنهی یک **نماینده سراسری** باشد، فقط پزشکانِ همان نماینده برمیگردند و `city_id`/`state_id` نادیده گرفته میشوند؛ دامنه شهری/ناشناخته اثری ندارد |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
|
||||
**امنیت verify:** `processCallback` داخل `EntityManager::wrapInTransaction` با `findByOrderIdForUpdate` (SELECT … FOR UPDATE) اجرا میشود؛ گاردِ «فقط `pending`» آن را **idempotent** میکند (verify تکراری/race بیاثر).
|
||||
|
||||
**کمیسیون دامنهمحور (post-action):** نمایندهی مبدأ از `payment.frontend_address` با `DomainContextResolver` تعیین میشود؛ کمیسیون (نوبت و اشتراک) فقط وقتی ثبت میشود که این نماینده فعال باشد **و** پزشک/کلینیک موضوع خرید `representation_id` همان نماینده را داشته باشد — جزئیات در `docs/api/representation.md` §قانون کمیسیون دامنهمحور.
|
||||
|
||||
**یکدستیِ typeها:** هر سه نوع (`appointment`/`subscription`/`sms_wallet`) از همان `GET /payment/pay/{orderId}` عبور میکنند؛ `PaymentManager::callbackUrl()` پیشوند callback را بر اساس `type` انتخاب میکند. POST این endpointها فقط `Payment` pending میسازد و `pay_url` برمیگرداند (نه `redirect_url`).
|
||||
|
||||
**افزودن درگاه جدید (Open/Closed):** یک کلاس جدید implements `PaymentGatewayInterface` بساز، در `GatewayFactory::$gateways` + `LABELS` ثبت کن. `PaymentController`/`PaymentManager` تغییر نمیکنند.
|
||||
|
||||
@@ -32,10 +32,14 @@ Create a new representation.
|
||||
|-------|------|----------|-------------|
|
||||
| `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) |
|
||||
| `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`
|
||||
```json
|
||||
{
|
||||
@@ -119,9 +123,9 @@ Update representation.
|
||||
}
|
||||
```
|
||||
|
||||
All fields optional.
|
||||
All fields optional. `city_ids: int[]` جایگزین `city_id` است (تکی هم پذیرفته میشود).
|
||||
|
||||
**Privileged fields:** `commission_percent` and `active` are **admin-only** — a representation editing its own record may change `full_name`, `city_id`, `bank_account` but **not** these two. `commission_percent` must be within `0–100`.
|
||||
**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.
|
||||
@@ -233,6 +237,40 @@ Get yearly earnings dashboard for a representation.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/site-context`
|
||||
|
||||
**عمومی (بدون auth).** نگاشت یک دامنه به زمینهی سایت — مصرفکننده: سایت عمومی nobat724 برای دامنههای خارج از `data/city.json` (دامنه اختصاصی نمایندگان سراسری).
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `domain` | string | ✅ | host یا URL کامل؛ نرمال میشود (scheme/www/پورت حذف) |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"type": "representation",
|
||||
"city": null,
|
||||
"representation": { "uuid": "...", "full_name": "نماینده الف", "is_global": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
`type`: `city` (دامنه یکی از شهرها) | `representation` (دامنه اختصاصی نماینده فعال) | `unknown`. برای `city`، آبجکت `city: {id, name}` پر میشود.
|
||||
|
||||
---
|
||||
|
||||
## قانون کمیسیون دامنهمحور
|
||||
|
||||
کمیسیون (نوبت **و** اشتراک) فقط وقتی ثبت میشود که **هر دو** شرط برقرار باشد:
|
||||
1. دامنهی مبدأ خرید (`payment.frontend_address`) متعلق به یک نمایندهی فعال باشد (`representations.domain`).
|
||||
2. پزشک/کلینیکِ موضوع خرید، `representation_id` همان نماینده را داشته باشد.
|
||||
|
||||
در غیر این صورت هیچ کمیسیونی برای هیچ نمایندهای ثبت نمیشود (پرداخت بدون `frontend_address` هم کمیسیون ندارد). درصد: نوبت = `commission_percent` نماینده؛ اشتراک = تنظیم سراسری `upgrade_commission_percent`. نگاشت دامنه فقط از طریق `DomainContextResolver` انجام میشود.
|
||||
|
||||
---
|
||||
|
||||
## پنل نماینده (ROLE_REPRESENTATION)
|
||||
|
||||
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین میشود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمیشود.
|
||||
|
||||
Reference in New Issue
Block a user