Files
clinicpro/.claude/prompt/representation-multi-city-domain-commission.md
hamed 0750bc9812 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.
2026-07-09 07:26:58 +03:30

13 KiB

نمایندگان: چند-شهری، کمیسیون دامنه‌محور، نماینده سراسری، سرویس مرکزی دامنه

پروژه

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 (کپی واقعی — فیلدهای کلیدی):

#[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، بعد از ساخت جدول):
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.domainstring(255) nullable unique. نرمال‌سازی هنگام ذخیره: lowercase، حذف https?://، حذف www.، حذف / انتهایی.
  • representations.is_globalboolean default false.
  • validation:
    • hostname معتبر (/^[a-z0-9.-]+\.[a-z]{2,}$/).
    • برخورد با cities.domain ممنوع → خطای conflict با پیام فارسی.
    • is_global=truecity_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:

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 (هر دو مسیر نوبت و اشتراک):

// 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):
{ "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 (پارامتر domainpayment.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 ندارد — در گزارش نهایی یادآوری کن.