- 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.
13 KiB
نمایندگان: چند-شهری، کمیسیون دامنهمحور، نماینده سراسری، سرویس مرکزی دامنه
پروژه
clinicpro — پرامپت همتا در فرانت: nobat724_front/.claude/prompt/global-rep-domain-site.md (بعد از این اجرا شود). قرارداد API این پرامپت توسط سایت عمومی مصرف میشود.
زمینه
نماینده (Representation) الان تکشهری است (representations.city_id)، دامنه ندارد و کمیسیون صرفاً بر اساس doctor.representation_id هنگام post-action پرداخت ثبت میشود. نیاز محصول: (۱) نماینده چند-شهری، (۲) کمیسیون فقط وقتی ثبت شود که دامنه مبدأ خرید متعلق به نماینده باشد و پزشک/کلینیک متعلق به همان نماینده، (۳) «نماینده سراسری» با دامنه اختصاصی که سایتش فقط پزشکان/کلینیکهای خودش را نشان میدهد، (۴) یک سرویس مرکزی تشخیص دامنه که تنها نقطهی نگاشت host → context باشد.
پیشنیاز معماری که از قبل موجود است (تحلیلشده):
Paymententity فیلد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_idFK→representations،city_idFK→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.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وdomainadmin-only (همان الگوی privileged fields فعلی مثلcommission_percentدر PATCH).
- hostname معتبر (
- خروجی
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(پارامتر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 ندارد — در گزارش نهایی یادآوری کن.