# نمایندگان: چند-شهری، کمیسیون دامنه‌محور، نماینده سراسری، سرویس مرکزی دامنه ## پروژه `clinicpro` — پرامپت همتا در فرانت: `nobat724_front/.claude/prompt/global-rep-domain-site.md` (بعد از این اجرا شود). قرارداد API این پرامپت توسط سایت عمومی مصرف می‌شود. ## زمینه نماینده (Representation) الان تک‌شهری است (`representations.city_id`)، دامنه ندارد و کمیسیون صرفاً بر اساس `doctor.representation_id` هنگام post-action پرداخت ثبت می‌شود. نیاز محصول: (۱) نماینده چند-شهری، (۲) کمیسیون فقط وقتی ثبت شود که **دامنه مبدأ خرید متعلق به نماینده** باشد **و** پزشک/کلینیک متعلق به همان نماینده، (۳) «نماینده سراسری» با دامنه اختصاصی که سایتش فقط پزشکان/کلینیک‌های خودش را نشان می‌دهد، (۴) یک سرویس مرکزی تشخیص دامنه که تنها نقطه‌ی نگاشت host → context باشد. **پیش‌نیاز معماری که از قبل موجود است (تحلیل‌شده):** - `Payment` entity فیلد `frontendAddress` (دامنه مبدأ) را ذخیره می‌کند — `docs/api/payment.md` §معماری. یعنی **مبنای دامنه‌محور بدون مکانیزم جدید در دسترس است.** - کمیسیون در `PaymentManager::processCallback` → post-action («confirm نوبت / فعال‌سازی اشتراک / … + کمیسیون») ثبت می‌شود و ردیفش `FinancialBreakdown` است (`representation_share_rials`, `commission_percent`, `gross_rials`, source=appointment|subscription) — `docs/api/representation.md` §مالی. - پزشک و کلینیک هر دو `representation_id` دارند (ست‌شده هنگام ثبت توسط نماینده — `POST /api/v1/representation/doctor|clinic`). - جدول `cities` ستون `representation_id` (نگاشت معکوس قدیمی city→rep) و ستون `domain` (مثل `yasuj-nobat.ir`) دارد. - نقش `ROLE_REPRESENTATION` برای پنل نماینده در `/admin`. ## مشکل / هدف شش قابلیت زیر بدون شکستن قراردادهای فعلی (BC در API و داده) پیاده شوند. **قبل از هر کدنویسی، تحلیل ساختار فعلی را کامل کن**: `graphify query` روی commission/representation، سپس این فایل‌ها را بخوان و در مرحله ② (طراحی) خلاصه ارائه کن — `src/Payment/Service/PaymentManager.php` (محل دقیق ثبت کمیسیون فعلی)، Entity/Service مربوط به `FinancialBreakdown`، `src/Representation/*`، کنترلر عمومی لیست پزشکان/کلینیک‌ها، صفحه نمایندگان در `assets/admin/pages/`. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Representation/Entity/Representation.php` | entity فعلی — `cityId` تکی، بدون domain/is_global | | `src/Representation/Controller/*` | CRUD ادمین + پنل نماینده | | `src/Payment/Service/PaymentManager.php` | post-action پرداخت — محل فعلی ثبت کمیسیون | | Entity/Repository `FinancialBreakdown` (grep: `representation_share_rials`) | ردیف مالی کمیسیون | | کنترلر عمومی `GET /api/v1/doctors` و `GET /api/v1/clinics` (front با `state_id/city_id/specialty_id/page/limit` صدا می‌زند) | باید فیلتر domain-scoped بگیرند | | `src/Location/Entity/City.php` | `domain` + `representation_id` موجود | | صفحه نمایندگان در `assets/admin/pages/` (grep: `admin/representations`) | فرم و لیست ادمین | | `docs/api/representation.md`، `admin.md`، `doctor.md`، `clinic.md`، `payment.md` | مستندات لازم‌الاصلاح | ## وضعیت فعلی `Representation` (کپی واقعی — فیلدهای کلیدی): ```php #[ORM\Column(name: 'city_id', type: 'integer', nullable: true)] private ?int $cityId = null; #[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)] private string $commissionPercent = '10.00'; #[ORM\Column(type: 'boolean')] private bool $active = true; public function toArray(): array { return [ 'uuid' => $this->uuid, 'full_name' => $this->fullName, 'mobile_number' => $this->mobileNumber, 'city_id' => $this->cityId, 'commission_percent' => $this->commissionPercent, 'bank_account' => $this->bankAccount, 'active' => $this->active, 'created_at' => $this->createdAt, ]; } ``` کمیسیون فعلی: فقط شرط «پزشکِ نوبت `representation_id` دارد» — دامنه هیچ نقشی ندارد. ## وظایف ### ۱. چند-شهری شدن نماینده (Entity + Migration با حفظ داده) - جدول join جدید `representation_cities` (`representation_id` FK→representations، `city_id` FK→cities، PK مرکب) — روی `Representation` به‌صورت `ManyToMany` به `City` با `#[ORM\JoinTable(name: 'representation_cities')]`. - Migration داده (در همان migration، بعد از ساخت جدول): ```sql INSERT IGNORE INTO representation_cities (representation_id, city_id) SELECT id, city_id FROM representations WHERE city_id IS NOT NULL; INSERT IGNORE INTO representation_cities (representation_id, city_id) SELECT representation_id, id FROM cities WHERE representation_id IS NOT NULL; ``` - ستون `representations.city_id` را **نگه دار** (deprecated؛ دیگر نوشته نمی‌شود)؛ `toArray()['city_id']` = اولین شهر collection (یا null) برای BC. - خروجی جدید `toArray()`: `city_ids: int[]` و `cities: [{id, name}]` (نام‌ها با join؛ در لیست‌های admin با DQL array hydration، نه getter). - API create/PATCH: پذیرش `city_ids: int[]` (BC: اگر فقط `city_id` آمد → `[city_id]`). validation: هر id موجود در cities. ### ۲. فیلدهای `domain` و `is_global` - `representations.domain` — `string(255) nullable unique`. نرمال‌سازی هنگام ذخیره: lowercase، حذف `https?://`، حذف `www.`، حذف `/` انتهایی. - `representations.is_global` — `boolean default false`. - validation: - hostname معتبر (`/^[a-z0-9.-]+\.[a-z]{2,}$/`). - برخورد با `cities.domain` ممنوع → خطای conflict با پیام فارسی. - `is_global=true` → `city_ids` اختیاری. `is_global` و `domain` **admin-only** (همان الگوی privileged fields فعلی مثل `commission_percent` در PATCH). - خروجی `toArray()` و لیست `/api/v1/admin/representations`: `domain` و `is_global` اضافه شود. ### ۳. سرویس مرکزی تشخیص دامنه — `DomainContextResolver` فایل جدید `src/Representation/Service/DomainContextResolver.php` — **تنها** نقطه‌ی نگاشت host → context در کل backend: ```php final class DomainContext { public function __construct( public readonly ?Representation $representation, // نماینده مالک دامنه public readonly ?City $city, // اگر دامنه‌ی یکی از شهرها بود public readonly bool $isGlobalRepresentation, ) {} } final class DomainContextResolver { public function resolve(?string $host): DomainContext { // normalize: lowercase، حذف port و www // 1) match با cities.domain → city // 2) match با representations.domain (active=1) → representation + isGlobal // 3) هیچ‌کدام → context خالی } } ``` - تطبیق هم full host (`yasuj-nobat.ir`) هم واریانت `www.` آن. - هیچ کنترلر/سرویس دیگری حق parse مستقیم دامنه ندارد — همه از این سرویس. ### ۴. کمیسیون دامنه‌محور (قانون جدید — مهم‌ترین بخش) در `PaymentManager` post-action (هر دو مسیر **نوبت** و **اشتراک**): ```php // pseudocode — جایگزین منطق فعلی $host = parse_url($payment->getFrontendAddress() ?? '', PHP_URL_HOST); $ctx = $this->domainContextResolver->resolve($host); $rep = $ctx->representation; // مالک دامنه‌ی مبدأ خرید $ownerRepId = $doctor?->getRepresentationId() ?? $clinic?->getRepresentationId(); if ($rep !== null && $rep->isActive() && $ownerRepId === $rep->getId()) { // FinancialBreakdown با representation_share_rials طبق commission_percent همان $rep } // در غیر این صورت: هیچ کمیسیونی ثبت نشود (نه نماینده دیگر، نه fallback قدیمی) ``` - شرط دوگانه صریح: «دامنه متعلق به نماینده» **و** «پزشک/کلینیک متعلق به همان نماینده». نبود هرکدام → صفر کمیسیون. - پرداخت بدون `frontendAddress` → کمیسیون ندارد. - اشتراک: مالکیت از `clinic.representation_id` کلینیکِ اشتراک. - داشبورد/گزارش‌های نماینده schema عوض نمی‌کنند (همه از `FinancialBreakdown` می‌خوانند) — فقط منبع ثبت. - **تست‌ها** (الگوی تست‌های Payment موجود، mock gateway): (a) دامنه rep + پزشک همان rep → ثبت؛ (b) دامنه rep دیگر → ثبت نشود؛ (c) بدون frontendAddress → ثبت نشود؛ (d) rep غیرفعال → ثبت نشود. ### ۵. فیلتر دامنه در لیست‌های عمومی + endpoint زمینه سایت پارامتر اختیاری `domain` به `GET /api/v1/doctors` و `GET /api/v1/clinics`: - resolve با `DomainContextResolver`: - **نماینده سراسری** → فقط ردیف‌های `representation_id = rep.id` (فیلترهای specialty/search اعمال شوند؛ `city_id/state_id` در این حالت نادیده). - شهر یا ناشناخته → رفتار فعلی بدون تغییر (نماینده شهری محدودیت نمایش **ندارد**). - endpoint عمومی جدید `GET /api/v1/site-context?domain=...` (بدون auth؛ در `public_endpoints` فایل security.yaml): ```json { "success": true, "data": { "type": "representation", "representation": { "uuid": "...", "full_name": "...", "is_global": true }, "city": null } } ``` `type`: `city` | `representation` | `unknown`. سایت عمومی برای دامنه‌های خارج از city.json از این endpoint استفاده می‌کند (قرارداد پرامپت فرانت). ### ۶. پنل ادمین (React) — فرم و لیست نمایندگان - فرم: نام، موبایل، **دامنه** (`input dir=ltr`)، **شهرها** (multi-select بر پایه `SearchableSelect`/الگوی موجود با chips)، **چک‌باکس «نماینده سراسری»** (فعال → select شهر disabled)، درصد کمیسیون، وضعیت فعال. - لیست: ستون شهرها (نام‌ها با «،»)، ستون دامنه، badge سبز «سراسری ✓» (`badge green`) برای `is_global`. - Types (`assets/admin/types/index.ts`): `city_ids: number[]`, `cities: {id: number; name: string}[]`, `domain?: string | null`, `is_global: boolean`. - Zod schema + payload builder مطابق الگوی صفحات فعلی (React Hook Form + zodResolver + TanStack Query). ## نکات مهم - **ترتیب**: Entity+Migration → Resolver → کمیسیون → APIهای عمومی → admin UI → docs. هر مرحله جدا تست. - migration داده idempotent (`INSERT IGNORE`) و حافظ داده قدیمی (هم `representations.city_id` هم `cities.representation_id`). - `cities.representation_id` حذف نشود (seed/front به schema وابسته) — deprecated اعلام شود. - خطاها با `AppException(ErrorCodes::...)` + پیام فارسی؛ کدهای جدید در `ErrorCodes.php`. - Edge caseها: دامنه با `www.`/پورت؛ برخورد دامنه rep با دامنه شهر (validation)؛ rep سراسری بدون دامنه (مجاز، فقط هشدار فرم)؛ unique بودن دامنه بین repها؛ پزشک بدون rep در دامنه rep (نوبت ثبت می‌شود، کمیسیون نه)؛ `is_global=true` با `city_ids` پر (مجاز — صرفاً metadata). - مستندات همزمان: `docs/api/representation.md` (فیلدها + site-context)، `doctor.md`/`clinic.md` (پارامتر `domain`)، `payment.md` (قانون کمیسیون در §معماری)، `admin.md`. - تست‌های موجود Representation/Payment سبز بمانند؛ phpstan سطح ۵ سبز؛ `tsc --noEmit` سبز؛ migration diff فقط تغییرات همین فیچر. - **نکته عملیاتی**: دامنه هر نماینده سراسری باید به `ALLOWED_FRONTEND_HOSTS` (env و `docker/frontend-domains.json` + `gen-cors-env.php`) و به Domains در Coolify اضافه شود وگرنه CORS/TLS ندارد — در گزارش نهایی یادآوری کن.