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:
hamed
2026-07-09 07:26:58 +03:30
parent 59559e2e31
commit 0750bc9812
56 changed files with 4297 additions and 1600 deletions
@@ -0,0 +1,177 @@
# نمایندگان: چند-شهری، کمیسیون دامنه‌محور، نماینده سراسری، سرویس مرکزی دامنه
## پروژه
`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 ندارد — در گزارش نهایی یادآوری کن.
+63 -19
View File
@@ -22,7 +22,9 @@ import SearchableSelect from '../components/ui/SearchableSelect';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
mobile_number: iranMobileSchema,
city_id: z.number().nullable().optional(),
city_ids: z.array(z.number()).optional(),
domain: z.string().optional(),
is_global: z.boolean().optional(),
commission_percent: z.coerce.number().min(0).max(100),
});
type FormData = z.infer<typeof schema>;
@@ -55,17 +57,20 @@ export default function RepresentationsPage() {
},
});
const { register, handleSubmit, reset, control, formState: { errors, isSubmitting } } = useForm<FormData>({
const { register, handleSubmit, reset, control, watch, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { commission_percent: 10 },
defaultValues: { commission_percent: 10, city_ids: [], is_global: false },
});
const isGlobal = watch('is_global') ?? false;
const createMutation = useMutation({
mutationFn: (d: FormData) =>
api.post<ApiResponse<Representation>>('/api/v1/representation', {
full_name: d.full_name,
mobile_number: d.mobile_number,
city_id: d.city_id ?? null,
city_ids: d.city_ids ?? [],
is_global: d.is_global ?? false,
...(d.domain?.trim() && { domain: d.domain.trim() }),
commission_percent: d.commission_percent,
}),
onSuccess: () => {
@@ -89,9 +94,15 @@ export default function RepresentationsPage() {
});
const columns: Column<Representation>[] = [
{ key: 'full_name', header: 'نام', render: (r) => <b>{r.full_name}</b> },
{ key: 'full_name', header: 'نام', render: (r) => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<b>{r.full_name}</b>
{r.is_global && <span className="badge green" title="نماینده سراسری">سراسری </span>}
</span>
) },
{ key: 'mobile_number', header: 'موبایل', render: (r) => <span dir="ltr">{r.mobile_number ?? '—'}</span> },
{ key: 'city', header: 'شهر', render: (r) => r.city ?? '—' },
{ key: 'domain', header: 'دامنه', render: (r) => r.domain ? <span dir="ltr">{r.domain}</span> : '—' },
{ key: 'city', header: 'شهرها', render: (r) => r.city ?? '—' },
{ key: 'commission_percent', header: 'کمیسیون', render: (r) => `${formatNumber(r.commission_percent)}٪` },
{ key: 'wallet_balance', header: 'موجودی کیف‌پول', render: (r) => formatRial(r.wallet_balance ?? 0) },
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active ?? r.active ?? false} /> },
@@ -183,21 +194,54 @@ export default function RepresentationsPage() {
{errors.mobile_number && <p className="err-text">{errors.mobile_number.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شهر</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" {...register('is_global')} style={{ width: 16, height: 16 }} />
نماینده سراسری (دامنه اختصاصی فقط پزشکان/کلینیکهای خودش نمایش داده میشوند)
</label>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>دامنه</label>
<input {...register('domain')} placeholder="example-nobat.ir" dir="ltr" className="input" />
{errors.domain && <p className="err-text">{errors.domain.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شهرها {isGlobal && <span className="muted">(برای نماینده سراسری اختیاری)</span>}</label>
<Controller
name="city_id"
name="city_ids"
control={control}
render={({ field }) => (
<SearchableSelect
options={cityOptions}
value={field.value ?? null}
onChange={(val) => field.onChange(val as number | null)}
placeholder="انتخاب شهر..."
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
)}
render={({ field }) => {
const selected: number[] = field.value ?? [];
return (
<div>
<SearchableSelect
options={cityOptions.filter((o) => !selected.includes(o.value))}
value={null}
onChange={(val) => {
if (val !== null && !selected.includes(val as number)) {
field.onChange([...selected, val as number]);
}
}}
placeholder={isGlobal ? 'اختیاری — افزودن شهر...' : 'افزودن شهر...'}
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
{selected.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selected.map((id) => (
<span key={id} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{cityOptions.find((o) => o.value === id)?.label ?? id}
<button type="button" className="mini-btn" style={{ padding: 0, width: 16, height: 16 }}
onClick={() => field.onChange(selected.filter((v) => v !== id))}>
×
</button>
</span>
))}
</div>
)}
</div>
);
}}
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
+4 -1
View File
@@ -147,9 +147,12 @@ export interface Representation {
id: number;
uuid: string;
full_name: string;
domain?: string;
domain?: string | null;
is_global?: boolean;
mobile_number: string | null;
city_id: number | null;
city_ids?: number[];
cities?: { id: number; name: string }[];
city: string | null;
commission_percent: number;
bank_account: { card?: string; bank_name?: string; iban?: string } | null;
+1
View File
@@ -62,6 +62,7 @@ security:
- { path: ^/api/v1/appointment-slots, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-settings/month-availability/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/site-context$, methods: [GET], roles: PUBLIC_ACCESS }
- { path: ^/api/v1/specialties, methods: [GET], roles: PUBLIC_ACCESS }
- path: '^/api/v1/rate/[^/]+$'
methods: [GET]
+7 -2
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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` تغییر نمی‌کنند.
+41 -3
View File
@@ -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 (0100) |
| `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 `0100`.
**Privileged fields:** `commission_percent`، `active`، `domain` و `is_global` **admin-only** هستند — نماینده روی رکورد خودش فقط `full_name`، `city_ids`، `bank_account` را می‌تواند تغییر دهد. `commission_percent` باید در بازه `0100` باشد. خطاهای `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 ورودی برای تعیین مالکیت پذیرفته نمی‌شود.
+14
View File
@@ -703,24 +703,38 @@
"701": "Community 701",
"702": "Community 702",
"703": "Community 703",
"704": "Community 704",
"705": "Community 705",
"706": "Community 706",
"707": "Community 707",
"708": "Community 708",
"709": "Community 709",
"710": "Community 710",
"711": "Community 711",
"712": "Community 712",
"713": "Community 713",
"714": "Community 714",
"715": "Community 715",
"716": "Community 716",
"717": "Community 717",
"718": "Community 718",
"719": "Community 719",
"720": "Community 720",
"721": "Community 721",
"722": "Community 722",
"723": "Community 723",
"724": "Community 724",
"725": "Community 725",
"726": "Community 726",
"727": "Community 727",
"728": "Community 728",
"729": "Community 729",
"730": "Community 730",
"731": "Community 731",
"732": "Community 732",
"733": "Community 733",
"734": "Community 734",
"735": "Community 735",
"736": "Community 736",
"738": "Community 738"
}
+177 -123
View File
@@ -1,16 +1,16 @@
# Graph Report - clinicpro (2026-07-08)
# Graph Report - clinicpro (2026-07-09)
## Corpus Check
- 720 files · ~528,271 words
- 726 files · ~532,146 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 9087 nodes · 12533 edges · 724 communities (578 shown, 146 thin omitted)
- 9155 nodes · 12640 edges · 738 communities (588 shown, 150 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 277 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `a5460cba`
- Built from commit: `59559e2e`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -704,30 +704,44 @@
- [[_COMMUNITY_Community 701|Community 701]]
- [[_COMMUNITY_Community 702|Community 702]]
- [[_COMMUNITY_Community 703|Community 703]]
- [[_COMMUNITY_Community 704|Community 704]]
- [[_COMMUNITY_Community 705|Community 705]]
- [[_COMMUNITY_Community 706|Community 706]]
- [[_COMMUNITY_Community 707|Community 707]]
- [[_COMMUNITY_Community 708|Community 708]]
- [[_COMMUNITY_Community 709|Community 709]]
- [[_COMMUNITY_Community 710|Community 710]]
- [[_COMMUNITY_Community 711|Community 711]]
- [[_COMMUNITY_Community 712|Community 712]]
- [[_COMMUNITY_Community 713|Community 713]]
- [[_COMMUNITY_Community 714|Community 714]]
- [[_COMMUNITY_Community 715|Community 715]]
- [[_COMMUNITY_Community 716|Community 716]]
- [[_COMMUNITY_Community 717|Community 717]]
- [[_COMMUNITY_Community 718|Community 718]]
- [[_COMMUNITY_Community 719|Community 719]]
- [[_COMMUNITY_Community 720|Community 720]]
- [[_COMMUNITY_Community 721|Community 721]]
- [[_COMMUNITY_Community 722|Community 722]]
- [[_COMMUNITY_Community 723|Community 723]]
- [[_COMMUNITY_Community 724|Community 724]]
- [[_COMMUNITY_Community 725|Community 725]]
- [[_COMMUNITY_Community 726|Community 726]]
- [[_COMMUNITY_Community 727|Community 727]]
- [[_COMMUNITY_Community 728|Community 728]]
- [[_COMMUNITY_Community 729|Community 729]]
- [[_COMMUNITY_Community 730|Community 730]]
- [[_COMMUNITY_Community 731|Community 731]]
- [[_COMMUNITY_Community 732|Community 732]]
- [[_COMMUNITY_Community 733|Community 733]]
- [[_COMMUNITY_Community 734|Community 734]]
- [[_COMMUNITY_Community 735|Community 735]]
- [[_COMMUNITY_Community 736|Community 736]]
- [[_COMMUNITY_Community 738|Community 738]]
## God Nodes (most connected - your core abstractions)
1. `BaseController` - 76 edges
2. `ApiTestCase` - 72 edges
1. `BaseController` - 78 edges
2. `ApiTestCase` - 74 edges
3. `api` - 55 edges
4. `UserProfile` - 52 edges
5. `Clinic` - 50 edges
@@ -744,15 +758,15 @@
assets/admin/components/ui/Pagination.tsx → assets/admin/lib/utils.ts
- `PersianDatePicker()` --calls--> `formatDate()` [EXTRACTED]
assets/admin/components/ui/PersianDatePicker.tsx → assets/admin/lib/utils.ts
- `MyFinancialPage()` --calls--> `formatRial()` [EXTRACTED]
assets/admin/pages/MyFinancialPage.tsx → assets/admin/lib/utils.ts
- `RepresentationSettlementPage()` --calls--> `formatRial()` [EXTRACTED]
assets/admin/pages/RepresentationSettlementPage.tsx → assets/admin/lib/utils.ts
- `SettlementsPage()` --calls--> `formatRial()` [EXTRACTED]
assets/admin/pages/SettlementsPage.tsx → assets/admin/lib/utils.ts
- `NewAppointmentModal()` --calls--> `useAuthStore` [EXTRACTED]
assets/admin/pages/AppointmentsPage.tsx → assets/admin/stores/authStore.ts
## Import Cycles
- None detected.
## Communities (724 total, 146 thin omitted)
## Communities (738 total, 150 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
@@ -771,8 +785,8 @@ Cohesion: 0.10
Nodes (20): `RepresentationActionController` — welcome به‌صورت inline (بدون تمپلت), `SmsMessageTemplate::DEFAULTS` (فاقد نام تمپلت و نگاشت token), `SmsService::sendNow` — انتخاب بین lookup و متن‌آزاد, الگوی فعلی همه‌ی call-siteها (به‌جز OTP) — متن‌آزاد، بدون `templateCode`, تبدیل همه‌ی پیامک‌های سیستمی به VerifyLookup کاوه‌نگار (تمپلت نام‌دار), تنها جای درست (OTP) — که باید الگوی بقیه شود, زمینه, فایل‌های مرتبط (+12 more)
### Community 4 - "Community 4"
Cohesion: 0.06
Nodes (8): DoctorService, Doctor, DoctorServiceRepository, Collection, self, User, WeeklySchedule, ManagerRegistry
Cohesion: 0.05
Nodes (11): DoctorServiceController, DoctorService, Doctor, DoctorServiceRepository, Collection, self, User, WeeklySchedule (+3 more)
### Community 5 - "Community 5"
Cohesion: 0.07
@@ -804,11 +818,11 @@ Nodes (4): UserActiveContextRepository, ManagerRegistry, User, UserActiveContext
### Community 12 - "Community 12"
Cohesion: 0.05
Nodes (39): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+31 more)
Nodes (44): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+36 more)
### Community 13 - "Community 13"
Cohesion: 0.07
Nodes (25): get, PaymentConfig, PaymentGatewayInfo, api, ApiError, getToken(), refreshOnce(), request() (+17 more)
Cohesion: 0.08
Nodes (20): get, api, ApiError, getToken(), refreshOnce(), request(), { refreshMock, logoutMock }, replaceMock (+12 more)
### Community 14 - "Community 14"
Cohesion: 0.15
@@ -823,8 +837,8 @@ Cohesion: 0.07
Nodes (7): PatientSession, SmsWallet, Appointment, Collection, PatientRecord, self, SessionService
### Community 17 - "Community 17"
Cohesion: 0.05
Nodes (38): DELETE `/api/v1/comment/{uuid}`, Errors, Errors, Errors, Errors, Errors, Errors, Errors (+30 more)
Cohesion: 0.06
Nodes (33): DELETE `/api/v1/comment/{uuid}`, Errors, Errors, Errors, Errors, Errors, Errors, Errors (+25 more)
### Community 18 - "Community 18"
Cohesion: 0.05
@@ -832,15 +846,15 @@ Nodes (38): API, API, API, API, API, Route, Route, Route (+30 more)
### Community 19 - "Community 19"
Cohesion: 0.04
Nodes (62): PaginatedResponse, formatDate(), STATUS_FILTERS, FILTERS, Breakdown, SOURCE_LABEL, Summary, STATUS_FILTERS (+54 more)
Nodes (56): PaginatedResponse, formatDate(), STATUS_FILTERS, AddForm, addSchema, ClinicsPage(), HUES_LIST, FILTERS (+48 more)
### Community 20 - "Community 20"
Cohesion: 0.14
Cohesion: 0.13
Nodes (3): AdminApiController, JsonResponse, Request
### Community 21 - "Community 21"
Cohesion: 0.04
Nodes (53): usePaymentConfig(), formatNumber(), formatRial(), emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm, periodSchema (+45 more)
Cohesion: 0.06
Nodes (29): formatNumber(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent, AdminStats, APPT_CLS, APPT_COLOR (+21 more)
### Community 22 - "Community 22"
Cohesion: 0.15
@@ -883,8 +897,8 @@ Cohesion: 0.06
Nodes (32): 10. Modal / Dialog, 11. Toast Notifications, 12. Empty States & Loading, 13. Page Header (هر صفحه), 14. تکنولوژی Stack, 15. Responsive Breakpoints, 16. Dark Mode (اختیاری — فاز دوم), 17. نمونه رنگ‌بندی صفحه داشبورد (+24 more)
### Community 33 - "Community 33"
Cohesion: 0.08
Nodes (21): CoverageRow, Draft, KIND, TenantInsurance, ServiceTariffModal(), TariffResponse, TariffRow, EMPTY_ITEMS (+13 more)
Cohesion: 0.07
Nodes (25): CoverageRow, Draft, KIND, TenantInsurance, ServiceTariffModal(), TariffResponse, TariffRow, EMPTY_ITEMS (+17 more)
### Community 34 - "Community 34"
Cohesion: 0.06
@@ -904,7 +918,7 @@ Nodes (31): API endpoint موجود برای آدرس دکتر:, `DELETE /api/v1
### Community 38 - "Community 38"
Cohesion: 0.22
Nodes (8): Authentication API, DELETE `/api/v1/notification-mobile/{target}`, Errors, Errors, GET `/api/v1/notification-mobile/{target}`, Notification Mobile (OTP), Response `200`, Response `200`
Nodes (8): Authentication API, DELETE `/api/v1/notification-mobile/{target}`, Errors, Notification Mobile (OTP), POST `/oauth/logout`, Request Body, Response `200`, Response `200`
### Community 39 - "Community 39"
Cohesion: 0.12
@@ -963,16 +977,16 @@ Cohesion: 0.07
Nodes (26): الزامات UI, باگ‌فیکس صفحه نوبت‌ها, باگ ۱ — کرش تقویم, باگ ۲ — روز هفته در DateNavigator, باگ ۳ — پیام «slot نیست», باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient, باگ ۵ — patient_mobile نشان می‌دهد موبایل پزشک, باگ ۶ — نوبت‌های رزرو شده در نمایش زمانبندی (+18 more)
### Community 53 - "Community 53"
Cohesion: 0.10
Nodes (13): AppLogRepository, PaymentLog, ClaimItemRepository, ClinicStaffRepository, PaymentLogRepository, PreRegistrationRepository, ServiceEntityRepository, ManagerRegistry (+5 more)
Cohesion: 0.08
Nodes (16): AppLogRepository, ClaimItemRepository, DoctorInsuranceRepository, InvoiceItemRepository, PreRegistrationRepository, TaxRateHistoryRepository, ServiceEntityRepository, ManagerRegistry (+8 more)
### Community 54 - "Community 54"
Cohesion: 0.10
Nodes (4): ClinicDoctorInvitation, Clinic, Doctor, User
### Community 55 - "Community 55"
Cohesion: 0.14
Nodes (3): Representation, self, User
Cohesion: 0.11
Nodes (4): Representation, Collection, self, User
### Community 56 - "Community 56"
Cohesion: 0.08
@@ -1003,8 +1017,8 @@ Cohesion: 0.08
Nodes (25): Bulk import / export, DELETE `/api/v1/admin/specialty/{id}`, Errors, Errors, Errors, Errors, GET `/api/v1/admin/specialties`, GET `/api/v1/specialties` (+17 more)
### Community 63 - "Community 63"
Cohesion: 0.05
Nodes (31): AbstractAuthenticator, AuthenticationException, ErrorCodes, AuthController, ClinicServiceController, PatientController, StaffController, AppException (+23 more)
Cohesion: 0.22
Nodes (5): AuthController, RateLimiterFactory, JsonResponse, Request, User
### Community 64 - "Community 64"
Cohesion: 0.29
@@ -1031,8 +1045,8 @@ Cohesion: 0.15
Nodes (12): رفع بهم‌ریختگی کامل پنل ادمین روی iPhone 8 (Safari/Chrome iOS), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+4 more)
### Community 71 - "Community 71"
Cohesion: 0.09
Nodes (20): AddForm, addSchema, ClinicsPage(), HUES_LIST, EMPTY, PreRegistration, STATUS_META, STATUS_TABS (+12 more)
Cohesion: 0.20
Nodes (8): AdminUser, ChangeRoleModal(), getPrimaryRole(), HUES_LIST, ROLE_META, ROLE_TABS, RoleBadge(), UserStats
### Community 72 - "Community 72"
Cohesion: 0.09
@@ -1083,8 +1097,8 @@ Cohesion: 0.07
Nodes (30): devDependencies, @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, core-js, @csstools/postcss-oklab-function, @hotwired/stimulus (+22 more)
### Community 86 - "Community 86"
Cohesion: 0.06
Nodes (15): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, KernelBrowser, CommentListNPlusOneTest (+7 more)
Cohesion: 0.07
Nodes (14): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, KernelBrowser, CommentPaginationTest (+6 more)
### Community 87 - "Community 87"
Cohesion: 0.10
@@ -1119,8 +1133,8 @@ Cohesion: 0.10
Nodes (20): 12. تنظیمات نوبت — تعطیلات, 76. 🔴 `DELETE` delete, 77. 🔵 `POST` post, 78. 🟡 `PATCH` patch, 79. 🔴 `DELETE` delete, 80. 🟢 `GET` get, Request Body, Request Body (+12 more)
### Community 95 - "Community 95"
Cohesion: 0.15
Nodes (4): ClinicSubscription, Payment, SubscriptionPeriod, SubscriptionPlan
Cohesion: 0.08
Nodes (7): ClinicSubscription, Holiday, Doctor, self, Payment, SubscriptionPeriod, SubscriptionPlan
### Community 96 - "Community 96"
Cohesion: 0.18
@@ -1167,12 +1181,12 @@ Cohesion: 0.11
Nodes (18): Endpoint ها, gate check در تسک‌های بعدی, GET /api/v1/subscription/my, GET /api/v1/subscription/plans, POST /api/v1/admin/subscription/period, POST /api/v1/subscription-payment (موجود), POST /api/v1/subscription/trial, POST /api/v1/subscription/trial (خطا — قبلاً استفاده شده) (+10 more)
### Community 107 - "Community 107"
Cohesion: 0.32
Cohesion: 0.33
Nodes (6): AppointmentController, Appointment, Doctor, JsonResponse, Request, User
### Community 108 - "Community 108"
Cohesion: 0.18
Nodes (7): BaseController, CategoryController, CategoryImportController, JsonResponse, JsonResponse, Request, JsonResponse
Cohesion: 0.13
Nodes (10): BaseController, CategoryController, CategoryImportController, SiteContextController, JsonResponse, JsonResponse, Request, JsonResponse (+2 more)
### Community 109 - "Community 109"
Cohesion: 0.29
@@ -1187,8 +1201,8 @@ Cohesion: 0.16
Nodes (3): DoctorService, self, Specialty
### Community 112 - "Community 112"
Cohesion: 0.18
Nodes (3): Holiday, Doctor, self
Cohesion: 0.06
Nodes (36): PaymentConfig, PaymentGatewayInfo, usePaymentConfig(), buildKavenegarPattern(), KavenegarGuide(), STATUS_LOG_META, Tab, TAG_FILTER_OPTIONS (+28 more)
### Community 113 - "Community 113"
Cohesion: 0.15
@@ -1403,8 +1417,8 @@ Cohesion: 0.10
Nodes (20): api.ir (استعلام هویت — Shahkar / IbanMatch), اتصال به دیتابیس‌های مستقل (الزامی), اسرار (الزامی — قبل از اولین دیپلوی), امنیت و منابع, بررسی سلامت, دامنه‌ها و CORS, دیپلوی‌های بعدی, راهنمای دیپلوی ClinicPro (Coolify + Docker Compose) (+12 more)
### Community 169 - "Community 169"
Cohesion: 0.16
Nodes (4): RanginehProvider, SmsService, SendSmsMessage, SmsProviderInterface
Cohesion: 0.32
Nodes (3): SmsService, SendSmsMessage, SmsProviderInterface
### Community 170 - "Community 170"
Cohesion: 0.13
@@ -1524,7 +1538,7 @@ Nodes (13): Endpoint ها, PATCH /api/v1/secretary/{uuid}, POST /api/v1/secretar
### Community 201 - "Community 201"
Cohesion: 0.15
Nodes (13): Admin API, Clinic Invitation Management, GET `/api/v1/admin/pre-registrations`, GET `/api/v1/admin/secretaries`, GET /api/v1/admin/settings, PATCH /api/v1/admin/settings, POST `/api/v1/admin/pre-registrations/{uuid}/approve`, POST `/api/v1/admin/pre-registrations/{uuid}/reject` (+5 more)
Nodes (13): Admin API, Clinic Invitation Management, GET `/api/v1/admin/secretaries`, GET /api/v1/admin/settings, GET `/api/v1/admin/settlements`, PATCH /api/v1/admin/settings, Query Parameters, Query Parameters (+5 more)
### Community 202 - "Community 202"
Cohesion: 0.15
@@ -1535,8 +1549,8 @@ Cohesion: 0.15
Nodes (12): Clinic Services API, DELETE /api/v1/service-item/{uuid}, DELETE /api/v1/service-section/{uuid}, GET /api/v1/service-items/{sectionUuid}, GET /api/v1/service-items/{uuid}/tariffs, GET /api/v1/service-sections, PATCH /api/v1/service-item/{uuid}, PATCH /api/v1/service-section/{uuid} (+4 more)
### Community 204 - "Community 204"
Cohesion: 0.16
Nodes (6): ExceptionSubscriber, SecurityHeadersSubscriber, EventSubscriberInterface, ExceptionEvent, LoggerInterface, ResponseEvent
Cohesion: 0.21
Nodes (6): AuthenticationException, ExceptionSubscriber, SecurityHeadersSubscriber, EventSubscriberInterface, ExceptionEvent, ResponseEvent
### Community 205 - "Community 205"
Cohesion: 0.10
@@ -1547,8 +1561,8 @@ Cohesion: 0.08
Nodes (14): MellatGateway, MockGateway, SepGateway, SoapClient, PaymentGatewayInterface, PaymentInitResult, PaymentRefundResult, PaymentVerifyResult (+6 more)
### Community 207 - "Community 207"
Cohesion: 0.15
Nodes (13): ۲. مدل داده و موجودیت‌ها, ۲.۱ کاربر (User), ۲.۱۰ پرداخت (Payment Types), ۲.۱۰.۱ پرداخت نوبت, ۲.۱۰.۲ پرداخت اشتراک, ۲.۱۱ پروفایل بیمار (Profile), ۲.۱۲ وبلاگ (Blog), ۲.۳ دکتر (Doctor) (+5 more)
Cohesion: 0.07
Nodes (30): ۲. مدل داده و موجودیت‌ها, ۲.۱ کاربر (User), ۲.۱۰ پرداخت (Payment Types), ۲.۱۰.۱ پرداخت نوبت, ۲.۱۰.۲ پرداخت اشتراک, ۲.۱۱ پروفایل بیمار (Profile), ۲.۱۲ وبلاگ (Blog), ۲.۱۳ نظرات، لایک و امتیازدهی (+22 more)
### Community 208 - "Community 208"
Cohesion: 0.23
@@ -1603,8 +1617,8 @@ Cohesion: 0.23
Nodes (5): PaymentRepository, Appointment, ManagerRegistry, Payment, User
### Community 225 - "Community 225"
Cohesion: 0.06
Nodes (32): Contract, InsuranceOption, KIND_LABEL, formatDateTime(), ALL_STATUSES, AppointmentDetailPage(), timeOf(), LEVEL_FILTER_OPTIONS (+24 more)
Cohesion: 0.07
Nodes (26): formatDateTime(), ALL_STATUSES, AppointmentDetailPage(), timeOf(), LogsPage(), SessionRow(), PAYMENT_TYPE_LABELS, PaymentDetailPage() (+18 more)
### Community 226 - "Community 226"
Cohesion: 0.15
@@ -1619,8 +1633,8 @@ Cohesion: 0.20
Nodes (10): Bulk import / export, DELETE `/api/v1/admin/insurance/{id}`, DELETE `/api/v1/insurance/{id}`, EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه, GET `/api/v1/insurance/{id}`, Insurance API, Response `200`, Response `200` (+2 more)
### Community 229 - "Community 229"
Cohesion: 0.15
Nodes (12): DELETE `/api/v1/representation/{uuid}`, Errors, Errors, GET `/api/v1/representation/{uuid}/dashboard/monthly`, GET `/api/v1/representation/{uuid}/dashboard/yearly`, Path Parameters, Query Parameters, Query Parameters (+4 more)
Cohesion: 0.14
Nodes (13): DELETE `/api/v1/representation/{uuid}`, Errors, Errors, GET `/api/v1/representation/{uuid}/dashboard/monthly`, GET `/api/v1/site-context`, Path Parameters, Query Parameters, Query Parameters (+5 more)
### Community 230 - "Community 230"
Cohesion: 0.35
@@ -1679,8 +1693,8 @@ Cohesion: 0.28
Nodes (4): SubscriptionService, ClinicSubscription, Payment, SubscriptionPlan
### Community 245 - "Community 245"
Cohesion: 0.36
Nodes (3): DoctorServiceController, JsonResponse, Request
Cohesion: 0.31
Nodes (6): ErrorCodes, ClinicServiceController, JsonResponse, Request, ServiceSection, User
### Community 246 - "Community 246"
Cohesion: 0.17
@@ -1719,8 +1733,8 @@ Cohesion: 0.18
Nodes (11): 1. 🔵 `POST` refresh token, 1. احراز هویت (Authentication), 2. 🟢 `GET` X-CSRF-Token, 3. 🟢 `GET` user info 🆕, Request Body, بخش دوم — مستند کامل API, خطاهای عمومی, فهرست مطالب (+3 more)
### Community 255 - "Community 255"
Cohesion: 0.18
Nodes (11): GET /api/v1/doctors/{id}, GET /api/v1/doctors/{id}/insurances, GET /api/v1/representations/{id}, GET /oauth/userinfo — اطلاعات کاربر (سازگار با دروپال), POST /api/v1/representations/{id}/bank-accounts, POST /oauth/token — تجدید توکن (Refresh), POST /oauth/token — ورود به سیستم, Task-02: احراز هویت (Authentication) (+3 more)
Cohesion: 0.11
Nodes (19): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, GET /api/v1/categories/cities — شهرها, GET /api/v1/categories/states — استان‌ها, GET /api/v1/doctors/{id}, GET /api/v1/doctors/{id}/insurances, GET /api/v1/representations/{id}, GET /oauth/userinfo — اطلاعات کاربر (سازگار با دروپال), POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین) (+11 more)
### Community 256 - "Community 256"
Cohesion: 0.29
@@ -1755,8 +1769,8 @@ Cohesion: 0.33
Nodes (4): RepresentationRepository, ManagerRegistry, Representation, User
### Community 265 - "Community 265"
Cohesion: 0.08
Nodes (27): calcFinalPrice(), EMPTY_RECORDS, EMPTY_SESSIONS, fileNumber(), getPatientName(), getPatientPhone(), InsurancePricing, MyPatientsPageInner() (+19 more)
Cohesion: 0.06
Nodes (33): Contract, InsuranceOption, KIND_LABEL, calcFinalPrice(), EMPTY_RECORDS, EMPTY_SESSIONS, fileNumber(), getPatientName() (+25 more)
### Community 266 - "Community 266"
Cohesion: 0.33
@@ -1791,7 +1805,7 @@ Cohesion: 0.18
Nodes (10): Endpoint های موجود که تغییر می‌کنند, GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX, GET /api/v1/dashboard/clinic, GET /api/v1/dashboard/doctor, تسک ۱۶: داشبورد هوشمند — چارت + فیلتر زمانی, توضیح, زمان تخمینی, فیلتر بازه زمانی (+2 more)
### Community 274 - "Community 274"
Cohesion: 0.21
Cohesion: 0.20
Nodes (5): Authentication, ClinicPro — API Documentation Index, Error Code Reference, Modules, Standard Response Envelope
### Community 275 - "Community 275"
@@ -1803,8 +1817,8 @@ Cohesion: 0.20
Nodes (9): Architecture Audit — ClinicPro Symfony 7 Migration, Architecture Score (بعد از اصلاحات), Architecture Violations, Executive Summary, Final Verdict (بعد از اصلاحات), Missing Requirements (کامل), اصلاحات اعمال‌شده (بعد از Audit), دلیل تصمیم (+1 more)
### Community 277 - "Community 277"
Cohesion: 0.20
Nodes (9): بخش اول — مستند محصول (PRD), فهرست کلی, کلینیک پرو — مستند جامع فنی و API, ۵. فهرست مشکلات شناسایی‌شده و اصلاحات لازم, ۶. پیوست, ۶.۱ دیاگرام وضعیت نوبت, ۶.۲ جریان کمیسیون, ۶.۳ بررسی محدودیت منشی (+1 more)
Cohesion: 0.08
Nodes (23): GET /api/v1/sms/balance — موجودی حساب پیامک, POST /api/v1/sms/queue — افزودن به صف, بخش اول — مستند محصول (PRD), ثبت‌نام دکتر — از طریق نماینده, ثبت‌نام دکتر — از طریق کلینیک, ثبت‌نام دکتر — مستقل, فهرست کلی, کلینیک پرو — مستند جامع فنی و API (+15 more)
### Community 279 - "Community 279"
Cohesion: 0.20
@@ -1896,7 +1910,7 @@ Nodes (3): PreRegistrationController, JsonResponse, Request
### Community 301 - "Community 301"
Cohesion: 0.04
Nodes (52): FreeVisitPrice(), Pricing, ApiResponse, cn(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile() (+44 more)
Nodes (60): FreeVisitPrice(), Pricing, ApiResponse, cn(), formatRial(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile() (+52 more)
### Community 302 - "Community 302"
Cohesion: 0.12
@@ -1907,8 +1921,8 @@ Cohesion: 0.12
Nodes (16): آماده‌سازی پروژه ClinicPro برای دیپلوی روی Coolify با Docker Compose, زمینه, فایل‌های مرتبط, نکات مهم (محدودیت‌ها و edge caseها), هدف, وظایف, ۱. ساخت `Dockerfile` چندمرحله‌ای, ۱۰. ساخت راهنمای `docs/deploy/coolify.md` (+8 more)
### Community 304 - "Community 304"
Cohesion: 0.04
Nodes (48): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 31. 🟡 `PATCH` patch, 33. 🔵 `POST` image_clinic, 34. 🔵 `POST` image logo, 35. 🟡 `PATCH` patch, 36. 🟢 `GET` get my rate, 40. 🔵 `POST` post (+40 more)
Cohesion: 0.22
Nodes (9): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 36. 🟢 `GET` get my rate, 6. کلینیک (Clinic), هدرهای اضافی, پارامترهای Query, پاسخ‌ها, پاسخ‌ها (+1 more)
### Community 306 - "Community 306"
Cohesion: 0.07
@@ -2031,8 +2045,8 @@ Cohesion: 0.39
Nodes (5): JsonContains, FunctionNode, Node, Parser, SqlWalker
### Community 340 - "Community 340"
Cohesion: 0.43
Nodes (3): InvoiceItemRepository, InvoiceItem, ManagerRegistry
Cohesion: 0.35
Nodes (5): PatientController, JsonResponse, PatientSession, Request, User
### Community 341 - "Community 341"
Cohesion: 0.12
@@ -2254,10 +2268,6 @@ Nodes (6): ClaimAmountBoundsTest, ClaimsListNPlusOneTest, ClaimItem, Claim, Doct
Cohesion: 0.22
Nodes (7): initiate(), refund(), reverse(), verify(), PaymentInitResult, PaymentRefundResult, PaymentVerifyResult
### Community 399 - "Community 399"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260628134241
### Community 401 - "Community 401"
Cohesion: 0.12
Nodes (15): `lib/utils.ts`, `SettingsPage.tsx` (ورودی‌ها ریال ذخیره می‌شوند), زمینه, فایل‌های مرتبط, مشکل / هدف, نمونهٔ نمایش (Subscription), نکات مهم, واحد پول = تومان در پنل ادمین (نمایش ÷۱۰ / ورودی ×۱۰) — ذخیره و درگاه ریال می‌ماند (+7 more)
@@ -2267,17 +2277,25 @@ Cohesion: 0.11
Nodes (17): بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (Backend), تست دستی (ddev، در حالت `payment_test_mode=1`), خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (+9 more)
### Community 407 - "Community 407"
Cohesion: 0.17
Nodes (11): MessageBusInterface, MockObject, SmsLogRepository, SmsServiceLookupOnlyTest, SmsLog, SmsLogRepository, SmsMessageTemplateRepository, SmsService (+3 more)
Cohesion: 0.27
Nodes (8): MessageBusInterface, MockObject, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver, KavehNegarProvider
### Community 418 - "Community 418"
Cohesion: 0.17
Nodes (11): دیپلوی ClinicPro روی Coolify (Docker Compose), رفع اشکال, مراحل دیپلوی, معماری دیپلوی, نکات عملیاتی, چند دامنه فرانت‌اند (مهم), ۱. ساخت Resource در Coolify, ۲. اختصاص دامنه (+3 more)
### Community 421 - "Community 421"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609132009, Schema, Version20260628165710
### Community 424 - "Community 424"
Cohesion: 0.33
Nodes (5): SecretaryController, DoctorSecretary, JsonResponse, Request, User
### Community 430 - "Community 430"
Cohesion: 0.13
Nodes (14): زمینه, فایل‌های مرتبط, مشکل / هدف, نمایندگان: چند-شهری، کمیسیون دامنه‌محور، نماینده سراسری، سرویس مرکزی دامنه, نکات مهم, وضعیت فعلی, وظایف, پروژه (+6 more)
### Community 432 - "Community 432"
Cohesion: 0.12
Nodes (16): backend (آماده — فقط annotation/doc ناقص), افزودن فیلد «تاریخ شروع فعالیت» (سال تجربه) در پنل ادمین با تقویم شمسی, زمینه, فایل‌های مرتبط, فرم ساخت پزشک — `DoctorFormPage.tsx` (خط ~282), فرم ویرایش پزشک — `DoctorDetailPage.tsx` (وضعیت فعلی، بدون فیلد تاریخ), مشکل / هدف, نکات مهم (+8 more)
@@ -2295,8 +2313,8 @@ Cohesion: 0.11
Nodes (18): [F10] راهنمای کهنه در `CLAUDE.md`: endpoint `categorys/{bundle}` منتقل شده, [F11] داشبورد دکتر `GET /api/v1/dashboard/doctor` همیشه 500 (فیلد ناموجود در DQL) — ✅ رفع شد, [F1] phpstan: مقایسهٔ همیشه‌درست در محاسبهٔ estimated SMS — ✅ رفع شد, [F2] تست‌های PHPUnit به API خارجی Kavenegar درخواست واقعی می‌زنند, [F3] دیتابیس تست seed نشده — فقط کاربر ادمین وجود دارد, [F4] اسکریپت seeder `create_test_users.php` وجود ندارد, [F5] ادمین با JWT معتبر به `/api/doc` (Swagger UI) دسترسی ندارد (401), [F6] ناسازگاری کدهای خطا بین دامنه‌ها (+10 more)
### Community 452 - "Community 452"
Cohesion: 0.48
Nodes (3): DoctorInsuranceRepository, DoctorInsurance, ManagerRegistry
Cohesion: 0.36
Nodes (5): StaffController, ClinicStaff, JsonResponse, Request, User
### Community 456 - "Community 456"
Cohesion: 0.15
@@ -2307,8 +2325,8 @@ Cohesion: 0.47
Nodes (6): formatPersianDate(), gToJ(), jFirstDayOfWeek(), PersianDateInput(), todayGregorian(), toPersianNums()
### Community 459 - "Community 459"
Cohesion: 0.40
Nodes (5): API موجود (نیاز به تغییر ندارند), اپیک ۳ — منشی (Secretary) — تکمیل, تغییرات مورد نیاز, توضیح, نیازمندی‌های کارکردی
Cohesion: 0.33
Nodes (6): API موجود (نیاز به تغییر ندارند), اپیک‌ها, اپیک ۳ — منشی (Secretary) — تکمیل, تغییرات مورد نیاز, توضیح, نیازمندی‌های کارکردی
### Community 460 - "Community 460"
Cohesion: 0.33
@@ -2375,7 +2393,7 @@ Cohesion: 0.33
Nodes (5): ایندکس‌ها, جدول: sms_accounts (حساب پیامک), جدول: sms_queue (صف پیامک), نکات مهم, پایگاه داده — تسک ۱۷: ماژول پیامک
### Community 478 - "Community 478"
Cohesion: 0.39
Cohesion: 0.36
Nodes (3): CityRepository, City, ManagerRegistry
### Community 479 - "Community 479"
@@ -2411,8 +2429,8 @@ Cohesion: 0.40
Nodes (5): Admin Endpoints, GET /api/v1/admin/sms/settings/review, GET /api/v1/admin/sms/wallet-report, POST /api/v1/admin/sms/settings/{id}/approve, POST /api/v1/admin/sms/settings/{id}/reject
### Community 490 - "Community 490"
Cohesion: 0.25
Nodes (8): ثبت‌نام دکتر — از طریق نماینده, ثبت‌نام دکتر — از طریق کلینیک, ثبت‌نام دکتر — مستقل, ۱. معرفی محصول, ۱.۱ نقش‌های سیستم, ۱.۲ فلوهای عملیاتی اصلی, ۱.۳ پلن‌های اشتراک, ۱.۴ سیستم پیامک
Cohesion: 0.15
Nodes (11): emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm, periodSchema, PLAN_DISPLAY, PlanForm, planSchema (+3 more)
### Community 491 - "Community 491"
Cohesion: 0.12
@@ -2447,8 +2465,8 @@ Cohesion: 0.40
Nodes (5): addMinutes(), calcSlotCount(), hasOverlap(), parseMinutes(), SessionEditor()
### Community 500 - "Community 500"
Cohesion: 0.33
Nodes (6): API موجود (نیاز به endpoint جدید ندارد), اپیک‌ها, اپیک ۷ — داشبورد هوشمند (Smart Dashboard), تغییر مورد نیاز, توضیح, نیازمندی‌های کارکردی
Cohesion: 0.40
Nodes (5): API موجود (نیاز به endpoint جدید ندارد), اپیک ۷ — داشبورد هوشمند (Smart Dashboard), تغییر مورد نیاز, توضیح, نیازمندی‌های کارکردی
### Community 501 - "Community 501"
Cohesion: 0.40
@@ -2567,8 +2585,8 @@ Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/user/verify-code`, Request Body, Response `200`
### Community 534 - "Community 534"
Cohesion: 0.13
Nodes (12): AdminUserDetail, AVATAR_COLORS, EditForm, editSchema, GENDER_LABELS, getPrimaryRole(), MARITAL_LABELS, MEDICAL_SECTIONS (+4 more)
Cohesion: 0.31
Nodes (4): DomainContextResolver, DomainCommissionTest, Payment, Representation
### Community 536 - "Community 536"
Cohesion: 0.12
@@ -2587,8 +2605,8 @@ Cohesion: 0.50
Nodes (4): 38. 🟢 `GET` Unapproved comments, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 543 - "Community 543"
Cohesion: 0.53
Nodes (3): TaxRateHistoryRepository, ManagerRegistry, TaxRateHistory
Cohesion: 0.30
Nodes (6): AbstractAuthenticator, Passport, PasswordAuthenticator, Request, Response, TokenInterface
### Community 544 - "Community 544"
Cohesion: 0.15
@@ -2762,12 +2780,8 @@ Nodes (3): ماژول‌های شناسایی‌شده در PRD, موارد پو
Cohesion: 0.67
Nodes (3): نقاط ضعف, نقاط قوت, ۶. تحلیل API Design
### Community 606 - "Community 606"
Cohesion: 0.25
Nodes (8): ۲.۲ انواع دسته‌بندی (Category Types), ۲.۲.۱ تگ (Tag), ۲.۲.۲ استان (State), ۲.۲.۳ شهر (City), ۲.۲.۴ بیمه پایه (Basic Insurance), ۲.۲.۵ بیمه مکمل (Supplementary Insurance), ۲.۲.۶ تخصص دکتر (Doctor Specialty), ۲.۲.۷ خدمات دکتر (Doctor Services)
### Community 607 - "Community 607"
Cohesion: 0.35
Cohesion: 0.34
Nodes (5): RepresentationController, JsonResponse, Representation, Request, User
### Community 612 - "Community 612"
@@ -2775,12 +2789,12 @@ Cohesion: 0.67
Nodes (3): بک‌اند, فرانت‌اند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
### Community 618 - "Community 618"
Cohesion: 0.13
Nodes (6): ServiceItemDeleteCleanupTest, TenantInsuranceCleanupTest, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, TenantServiceCoverage
Cohesion: 0.10
Nodes (7): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantInsuranceCleanupTest, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, TenantServiceCoverage
### Community 625 - "Community 625"
Cohesion: 0.40
Nodes (5): Errors, Path Parameters, POST `/api/v1/admin/clinic/{uuid}/invite-doctor`, Request Body (`application/json`), Response `201`
Cohesion: 0.22
Nodes (3): AppException, SlotTakenException, RuntimeException
### Community 631 - "Community 631"
Cohesion: 0.50
@@ -2814,6 +2828,10 @@ Nodes (4): Errors, POST `/file/upload/clinic_pro/doctor/field_image`, Request, R
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/clinic-pro/doctor-address`, Request Body, Response `201`
### Community 641 - "Community 641"
Cohesion: 0.39
Nodes (3): ClinicStaffRepository, ClinicStaff, ManagerRegistry
### Community 647 - "Community 647"
Cohesion: 0.14
Nodes (13): اصلاح فیلتر شهر/استان در لیست عمومی پزشکان (`GET /api/v1/doctors`), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+5 more)
@@ -2855,8 +2873,8 @@ Cohesion: 0.31
Nodes (4): ClinicInvitationService, Clinic, ClinicDoctorInvitation, User
### Community 661 - "Community 661"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/settlements`, Query Parameters, Response `200`, Settlement Management
Cohesion: 0.43
Nodes (3): SmsLogRepository, SmsLog, ManagerRegistry
### Community 662 - "Community 662"
Cohesion: 0.29
@@ -2902,10 +2920,6 @@ Nodes (5): GET `/api/v1/admin/comments`, GET `/api/v1/admin/rates`, Query Parame
Cohesion: 0.50
Nodes (4): Errors, PATCH `/api/v1/admin/insurance/{id}`, Path Parameters, Response `200`
### Community 688 - "Community 688"
Cohesion: 0.67
Nodes (3): POST `/oauth/logout`, Request Body, Response `200`
### Community 689 - "Community 689"
Cohesion: 0.50
Nodes (4): PUT `/api/v1/insurance-pricing`, Request Body, Response `200`, خطاها
@@ -2938,10 +2952,6 @@ Nodes (3): Errors, POST `/api/v1/sms/send` — ⛔ غیرفعال (Deprecated),
Cohesion: 0.17
Nodes (11): رفع خطاهای ارسال پیامک کاوه‌نگار در سرور prod (431 + Idle timeout), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+3 more)
### Community 699 - "Community 699"
Cohesion: 0.33
Nodes (6): GET /api/v1/sms/balance — موجودی حساب پیامک, POST /api/v1/sms/queue — افزودن به صف, ۴. سیستم حساب و صف پیامک, ۴.۱ حساب پیامک (SMS Account), ۴.۲ صف پیامک (SMS Queue), ۴.۳ API پیامک
### Community 701 - "Community 701"
Cohesion: 0.17
Nodes (11): رفع خطای `Class "SoapClient" not found` در پرداخت ملت (سرور prod), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+3 more)
@@ -2955,8 +2965,8 @@ Cohesion: 0.40
Nodes (5): 37. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 706 - "Community 706"
Cohesion: 0.40
Nodes (5): ۲.۸ تنظیمات نوبت (Appointment Settings), ۲.۸.۱ برنامه هفتگی (Weekly Schedule), ۲.۸.۲ تعطیلات (Holidays), ۲.۸.۳ لغو تعطیل (Date Override), ۲.۸.۴ الگوریتم محاسبه اسلات‌های خالی
Cohesion: 0.53
Nodes (3): PaymentLog, PaymentLogRepository, ManagerRegistry
### Community 707 - "Community 707"
Cohesion: 0.38
@@ -2974,22 +2984,38 @@ Nodes (4): 39. 🟡 `PATCH` Comment confirmation, مثال Request, هدرهای
Cohesion: 0.50
Nodes (4): 44. 🟢 `GET` list comment, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 713 - "Community 713"
Cohesion: 0.40
Nodes (5): Errors, Path Parameters, POST `/api/v1/like/{commentUuid}`, Request Body (`application/json`), Response `200`
### Community 714 - "Community 714"
Cohesion: 0.40
Nodes (5): 31. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 715 - "Community 715"
Cohesion: 0.50
Nodes (4): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین), POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر, Task-09: API تنظیمات نوبت
Cohesion: 0.40
Nodes (5): 35. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 716 - "Community 716"
Cohesion: 0.50
Nodes (4): GET /api/v1/categories/cities — شهرها, GET /api/v1/categories/states — استان‌ها, Task-08: API دسته‌بندی‌ها, سایر Endpoint های دسته‌بندی — الزامی
### Community 717 - "Community 717"
Cohesion: 0.50
Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظر (Comment), ۲.۱۳.۲ لایک (Like), ۲.۱۳.۳ امتیاز (Rate)
Cohesion: 0.40
Nodes (5): 41. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 720 - "Community 720"
Cohesion: 0.33
Nodes (3): Closure, CorsRegexEnvProcessor, EnvVarProcessorInterface
### Community 721 - "Community 721"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/pre-registrations`, POST `/api/v1/admin/pre-registrations/{uuid}/approve`, POST `/api/v1/admin/pre-registrations/{uuid}/reject`, Pre-Registration Management
### Community 723 - "Community 723"
Cohesion: 0.50
Nodes (4): 40. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 724 - "Community 724"
Cohesion: 0.50
Nodes (4): 45. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 725 - "Community 725"
Cohesion: 0.43
Nodes (3): InvoiceService, Invoice, PatientSession
@@ -2998,6 +3024,34 @@ Nodes (3): InvoiceService, Invoice, PatientSession
Cohesion: 0.53
Nodes (4): Money, BillingCalculator, CoverageRule, ShareBreakdown
### Community 728 - "Community 728"
Cohesion: 0.50
Nodes (4): 46. 🟡 `PATCH` patch, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 730 - "Community 730"
Cohesion: 0.67
Nodes (3): Errors, GET `/api/v1/notification-mobile/{target}`, Response `200`
### Community 731 - "Community 731"
Cohesion: 0.67
Nodes (3): GET `/api/v1/representation/{uuid}/dashboard/yearly`, Query Parameters, Response `200`
### Community 732 - "Community 732"
Cohesion: 0.67
Nodes (3): 33. 🔵 `POST` image_clinic, هدرهای اضافی, پاسخ‌ها
### Community 733 - "Community 733"
Cohesion: 0.67
Nodes (3): 34. 🔵 `POST` image logo, هدرهای اضافی, پاسخ‌ها
### Community 734 - "Community 734"
Cohesion: 0.67
Nodes (3): 42. 🔴 `DELETE` delete, هدرهای اضافی, پاسخ‌ها
### Community 735 - "Community 735"
Cohesion: 0.67
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخ‌ها
### Community 736 - "Community 736"
Cohesion: 0.40
Nodes (3): Props, StatTone, TONE
@@ -3007,21 +3061,21 @@ Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/representation/{uuid}`, Path Parameters, Response `200`
## Knowledge Gaps
- **3978 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+3973 more)
- **3995 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+3990 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **146 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 654`, `Community 655`, `Community 15`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 424`, `Community 300`, `Community 435`, `Community 308`, `Community 59`, `Community 318`, `Community 63`, `Community 64`, `Community 75`, `Community 77`, `Community 607`, `Community 230`, `Community 104`, `Community 107`, `Community 109`, `Community 245`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.030) - this node is a cross-community bridge._
- **Why does `ApiTestCase` connect `Community 86` to `Community 609`, `Community 541`, `Community 641`, `Community 484`, `Community 577`, `Community 41`, `Community 618`, `Community 397`, `Community 497`, `Community 594`, `Community 562`, `Community 82`, `Community 565`, `Community 371`, `Community 535`, `Community 573`, `Community 574`, `Community 575`?**
_High betweenness centrality (0.024) - this node is a cross-community bridge._
- **Why does `Version20260705070546` connect `Community 646` to `Community 399`?**
- **Why does `BaseController` connect `Community 108` to `Community 4`, `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 654`, `Community 655`, `Community 15`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 424`, `Community 300`, `Community 435`, `Community 308`, `Community 59`, `Community 318`, `Community 63`, `Community 64`, `Community 452`, `Community 75`, `Community 77`, `Community 340`, `Community 607`, `Community 230`, `Community 104`, `Community 107`, `Community 109`, `Community 245`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.033) - this node is a cross-community bridge._
- **Why does `ApiTestCase` connect `Community 86` to `Community 397`, `Community 534`, `Community 535`, `Community 541`, `Community 41`, `Community 562`, `Community 565`, `Community 573`, `Community 574`, `Community 575`, `Community 577`, `Community 717`, `Community 594`, `Community 82`, `Community 609`, `Community 484`, `Community 618`, `Community 497`, `Community 371`?**
_High betweenness centrality (0.025) - this node is a cross-community bridge._
- **Why does `Version20260705070546` connect `Community 646` to `Community 421`?**
_High betweenness centrality (0.019) - this node is a cross-community bridge._
- **What connects `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps` to the rest of the system?**
_3978 weakly-connected nodes found - possible documentation gaps or missing edges._
_3995 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.05200501253132832 - nodes in this community are weakly interconnected._
- **Should `Community 1` be split into smaller, more focused modules?**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_collect_diagnostics_sh", "label": "collect-diagnostics.sh", "file_type": "code", "source_file": "docs/collect-diagnostics.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "file"}}, {"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_collect_diagnostics_sh__entry", "label": "collect-diagnostics.sh script", "file_type": "code", "source_file": "docs/collect-diagnostics.sh", "source_location": "L1", "metadata": {"language": "bash", "kind": "bash_entrypoint"}}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_collect_diagnostics_sh", "target": "users_hamed_pj_my_pj_clinic_pro_clinicpro_docs_collect_diagnostics_sh__entry", "relation": "contains", "confidence": "EXTRACTED", "source_file": "docs/collect-diagnostics.sh", "source_location": "L1", "weight": 1.0}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_representation_service_domaincontext_php", "label": "DomainContext.php", "file_type": "code", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L1"}, {"id": "service_domaincontext_domaincontext", "label": "DomainContext", "file_type": "code", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L9"}, {"id": "service_domaincontext_domaincontext_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L11"}, {"id": "service_domaincontext_domaincontext_empty", "label": ".empty()", "file_type": "code", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L17"}, {"id": "self", "label": "self", "file_type": "code", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L17"}, {"id": "service_domaincontext_domaincontext_representationid", "label": ".representationId()", "file_type": "code", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L22"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_representation_service_domaincontext_php", "target": "city", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L5", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_representation_service_domaincontext_php", "target": "representation", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L6", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_representation_service_domaincontext_php", "target": "service_domaincontext_domaincontext", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L9", "weight": 1.0}, {"source": "service_domaincontext_domaincontext", "target": "service_domaincontext_domaincontext_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L11", "weight": 1.0}, {"source": "service_domaincontext_domaincontext", "target": "service_domaincontext_domaincontext_empty", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L17", "weight": 1.0}, {"source": "service_domaincontext_domaincontext_empty", "target": "self", "relation": "references", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L17", "weight": 1.0, "context": "return_type"}, {"source": "service_domaincontext_domaincontext", "target": "service_domaincontext_domaincontext_representationid", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Representation/Service/DomainContext.php", "source_location": "L22", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+3170 -1369
View File
File diff suppressed because it is too large Load Diff
+74 -44
View File
@@ -330,8 +330,8 @@
"semantic_hash": ""
},
"assets/admin/pages/RepresentationsPage.tsx": {
"mtime": 1781869996.049502,
"ast_hash": "209d7dbe4157ebe69748d71acc2aa853",
"mtime": 1783569345.7762475,
"ast_hash": "55e5abec0221125a26d09949eb7e3f20",
"semantic_hash": ""
},
"assets/admin/pages/SecretariesPage.tsx": {
@@ -400,8 +400,8 @@
"semantic_hash": ""
},
"assets/admin/types/index.ts": {
"mtime": 1783499946.4879165,
"ast_hash": "edd28014dfe716c5bdaf1743e17bfea4",
"mtime": 1783569345.7764525,
"ast_hash": "caee3b2fd870a2e6f75ee6fb889ec0b1",
"semantic_hash": ""
},
"assets/app.js": {
@@ -810,8 +810,8 @@
"semantic_hash": ""
},
"src/Admin/Controller/AdminApiController.php": {
"mtime": 1783455997.6031876,
"ast_hash": "b17487b206b1483b616160fabb15f429",
"mtime": 1783569345.7785833,
"ast_hash": "76990cb279d535ab2d869f87752a185a",
"semantic_hash": ""
},
"src/Admin/Controller/AdminController.php": {
@@ -825,8 +825,8 @@
"semantic_hash": ""
},
"src/Appointment/Controller/AppointmentController.php": {
"mtime": 1782304270.2436676,
"ast_hash": "4af2f29806e8974c8f8b33af69a3b0a0",
"mtime": 1783569345.7788084,
"ast_hash": "f0cef49f9e7857799c71ea434525dbef",
"semantic_hash": ""
},
"src/Appointment/Controller/AppointmentSettingsController.php": {
@@ -1075,8 +1075,8 @@
"semantic_hash": ""
},
"src/Clinic/Controller/ClinicController.php": {
"mtime": 1782035216.5144877,
"ast_hash": "6a25be705a1b2c5e1ea41878a25009fb",
"mtime": 1783569345.779344,
"ast_hash": "4a12996b6afa6abb733c8f3f4da1c891",
"semantic_hash": ""
},
"src/Clinic/Entity/Clinic.php": {
@@ -1085,8 +1085,8 @@
"semantic_hash": ""
},
"src/Clinic/Repository/ClinicRepository.php": {
"mtime": 1781782025.3981936,
"ast_hash": "d3cd179c5e663867a2db6e465eb0cd73",
"mtime": 1783569345.779688,
"ast_hash": "7bce0f0c753de7786d7e5d6c5068724e",
"semantic_hash": ""
},
"src/ClinicInvitation/Controller/ClinicInvitationController.php": {
@@ -1180,8 +1180,8 @@
"semantic_hash": ""
},
"src/Doctor/Controller/DoctorController.php": {
"mtime": 1782978998.6554778,
"ast_hash": "86fbb75fbf607a364e8b98bcf921076a",
"mtime": 1783569345.7800236,
"ast_hash": "c056cab3f38fa4a224564ac3a2d4b728",
"semantic_hash": ""
},
"src/Doctor/Entity/Doctor.php": {
@@ -1200,8 +1200,8 @@
"semantic_hash": ""
},
"src/Doctor/Repository/DoctorRepository.php": {
"mtime": 1782977943.3100293,
"ast_hash": "8107334c65e7a5db72bfe6f94875b463",
"mtime": 1783569345.7805371,
"ast_hash": "90a5e3ccc3f1997cd50b5a2cb67d37e2",
"semantic_hash": ""
},
"src/DoctorService/Controller/DoctorServiceController.php": {
@@ -1310,8 +1310,8 @@
"semantic_hash": ""
},
"src/Location/Repository/CityRepository.php": {
"mtime": 1781085493.5616481,
"ast_hash": "bb4f54064d492b4d00aa0a8a21c7cb02",
"mtime": 1783569345.7806973,
"ast_hash": "72c4e4ad42c3709b056a0f248564504b",
"semantic_hash": ""
},
"src/Location/Repository/ProvinceRepository.php": {
@@ -1450,18 +1450,18 @@
"semantic_hash": ""
},
"src/Representation/Controller/RepresentationController.php": {
"mtime": 1782728407.2007582,
"ast_hash": "321713adc028713c1ead67a8ac9fbc09",
"mtime": 1783569345.781025,
"ast_hash": "669db8838feadee4a2515ddb46512d97",
"semantic_hash": ""
},
"src/Representation/Entity/Representation.php": {
"mtime": 1782650883.6145928,
"ast_hash": "b40403a32c980865f30ab428cd2f83ca",
"mtime": 1783569345.7812188,
"ast_hash": "a131cbff7a6d64e29c5f3ab61b470948",
"semantic_hash": ""
},
"src/Representation/Repository/RepresentationRepository.php": {
"mtime": 1782304285.8684978,
"ast_hash": "9a8035b3d8f18a47625f09e7665b3ecb",
"mtime": 1783569345.78135,
"ast_hash": "790e3fa6a3be7229e118cbc5e0cd9320",
"semantic_hash": ""
},
"src/Representation/Service/JalaliDateService.php": {
@@ -1530,8 +1530,8 @@
"semantic_hash": ""
},
"src/Settlement/Service/CommissionService.php": {
"mtime": 1782304402.8866458,
"ast_hash": "0257455b074592fb441b733cacced591",
"mtime": 1783569345.781548,
"ast_hash": "c442f7ef0e0ab80172f8cdeb8cc09593",
"semantic_hash": ""
},
"src/Shared/Constant/ErrorCodes.php": {
@@ -2260,8 +2260,8 @@
"semantic_hash": ""
},
"config/packages/security.yaml": {
"mtime": 1782995001.0929694,
"ast_hash": "3b126684770e87f7fef6545e099751fa",
"mtime": 1783569345.7766566,
"ast_hash": "6d04ed4f39e92b722ac727559194623a",
"semantic_hash": ""
},
"config/packages/twig.yaml": {
@@ -2340,8 +2340,8 @@
"semantic_hash": ""
},
"docs/api/admin.md": {
"mtime": 1783456077.2420146,
"ast_hash": "696cb26081d025472722594dfdc32152",
"mtime": 1783569345.7769597,
"ast_hash": "c0e70f627d969f3449dbe58fe653a9bf",
"semantic_hash": ""
},
"docs/api/appointment-settings.md": {
@@ -2380,8 +2380,8 @@
"semantic_hash": ""
},
"docs/api/clinic.md": {
"mtime": 1782035382.8888016,
"ast_hash": "24d7edcb8e5218250a6f7653e43c5d4f",
"mtime": 1783569345.777287,
"ast_hash": "c7b11dfac717f33e2b39372bb6d64c88",
"semantic_hash": ""
},
"docs/api/dashboard.md": {
@@ -2395,8 +2395,8 @@
"semantic_hash": ""
},
"docs/api/doctor.md": {
"mtime": 1782979047.5438921,
"ast_hash": "7f95fa1394ef1d84899929b5f2cf4d64",
"mtime": 1783569345.7776468,
"ast_hash": "70d56cf0705dfbfa35d4ea282536acfe",
"semantic_hash": ""
},
"docs/api/insurance.md": {
@@ -2415,8 +2415,8 @@
"semantic_hash": ""
},
"docs/api/payment.md": {
"mtime": 1783059802.8154833,
"ast_hash": "324965b58abe6317caab5653c982dfa7",
"mtime": 1783569345.7778866,
"ast_hash": "7e3149d1eb32cfdd150b44f29598e402",
"semantic_hash": ""
},
"docs/api/rating.md": {
@@ -2425,8 +2425,8 @@
"semantic_hash": ""
},
"docs/api/representation.md": {
"mtime": 1782979067.5559928,
"ast_hash": "0766b25592e2868c3e87d4e38958fa2e",
"mtime": 1783569345.77808,
"ast_hash": "2c4bbddefd4346abb457d327f2d8e6ae",
"semantic_hash": ""
},
"docs/api/secretary.md": {
@@ -3540,8 +3540,8 @@
"semantic_hash": ""
},
"src/Payment/Service/PaymentManager.php": {
"mtime": 1783239408.129024,
"ast_hash": "51937725073adf1f98391b485befaa79",
"mtime": 1783569345.7808533,
"ast_hash": "598f5727567765180a0fb1a9a7e60637",
"semantic_hash": ""
},
"tests/Payment/MellatGatewayTest.php": {
@@ -3745,8 +3745,8 @@
"semantic_hash": ""
},
"docs/ops-restarts.md": {
"mtime": 1783520952.2040253,
"ast_hash": "dd4692cc3fd108adf9cc15cb56999427",
"mtime": 1783528554.1951141,
"ast_hash": "6594570089394fd7591c73d421bf426d",
"semantic_hash": ""
},
"migrations/Version20260708083446.php": {
@@ -3755,13 +3755,43 @@
"semantic_hash": ""
},
"docs/collect-diagnostics.sh": {
"mtime": 1783520903.6040976,
"ast_hash": "5f2a05a7c797b9d61d22052335d2836b",
"mtime": 1783528559.0945294,
"ast_hash": "e4d454d67401331938ae2b169bf83cba",
"semantic_hash": ""
},
".claude/prompt/server-down-oom-diagnosis.md": {
"mtime": 1783520770.2451262,
"ast_hash": "6cc51cccf53e21a9a25fc6208b0357dd",
"semantic_hash": ""
},
"migrations/Version20260709033925.php": {
"mtime": 1783569345.8005955,
"ast_hash": "801ed6f414d3ced74f63528d71de2560",
"semantic_hash": ""
},
"src/Representation/Controller/SiteContextController.php": {
"mtime": 1783569345.8007598,
"ast_hash": "0414946458022dbf078687189c80d14d",
"semantic_hash": ""
},
"src/Representation/Service/DomainContext.php": {
"mtime": 1783569345.8009496,
"ast_hash": "e7c7530c013778f3078db9c445e0d8c3",
"semantic_hash": ""
},
"src/Representation/Service/DomainContextResolver.php": {
"mtime": 1783569345.8010752,
"ast_hash": "85a774dafdbdc6e9a9c619943e8cf06c",
"semantic_hash": ""
},
"tests/Representation/DomainCommissionTest.php": {
"mtime": 1783569345.8012395,
"ast_hash": "d5a6e6c27e5cde29618dc797b86589f9",
"semantic_hash": ""
},
".claude/prompt/representation-multi-city-domain-commission.md": {
"mtime": 1783569345.800353,
"ast_hash": "7405ad1b5e6eb371ea5955ffb70195bc",
"semantic_hash": ""
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260709033925 extends AbstractMigration
{
public function getDescription(): string
{
return 'Representation multi-city (representation_cities) + domain + is_global; backfills from legacy representations.city_id and cities.representation_id';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE representation_cities (representation_id INT NOT NULL, city_id INT NOT NULL, INDEX IDX_2BE110B046CE82F4 (representation_id), INDEX IDX_2BE110B08BAC62AF (city_id), PRIMARY KEY (representation_id, city_id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE representation_cities ADD CONSTRAINT FK_2BE110B046CE82F4 FOREIGN KEY (representation_id) REFERENCES representations (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE representation_cities ADD CONSTRAINT FK_2BE110B08BAC62AF FOREIGN KEY (city_id) REFERENCES cities (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE representations ADD domain VARCHAR(255) DEFAULT NULL, ADD is_global TINYINT NOT NULL DEFAULT 0');
$this->addSql('CREATE UNIQUE INDEX UNIQ_C90A401A7A91E0B ON representations (domain)');
// Backfill from both legacy single-city mappings; idempotent and only for still-existing cities.
$this->addSql('INSERT IGNORE INTO representation_cities (representation_id, city_id)
SELECT r.id, r.city_id FROM representations r JOIN cities c ON c.id = r.city_id');
$this->addSql('INSERT IGNORE INTO representation_cities (representation_id, city_id)
SELECT c.representation_id, c.id FROM cities c JOIN representations r ON r.id = c.representation_id');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE representation_cities DROP FOREIGN KEY FK_2BE110B046CE82F4');
$this->addSql('ALTER TABLE representation_cities DROP FOREIGN KEY FK_2BE110B08BAC62AF');
$this->addSql('DROP TABLE representation_cities');
$this->addSql('DROP INDEX UNIQ_C90A401A7A91E0B ON representations');
$this->addSql('ALTER TABLE representations DROP domain, DROP is_global');
}
}
+37 -16
View File
@@ -1013,10 +1013,9 @@ class AdminApiController extends BaseController
$cityId = $request->query->get('city_id');
$qb = $this->em->createQueryBuilder()
->select('r.id, r.uuid, r.fullName, r.mobileNumber, u.mobileNumber as user_mobile, r.cityId, r.commissionPercent, r.active, r.createdAt, c.name as city_name')
->select('r.id, r.uuid, r.fullName, r.mobileNumber, u.mobileNumber as user_mobile, r.cityId, r.domain, r.isGlobal, r.commissionPercent, r.active, r.createdAt')
->from(Representation::class, 'r')
->join('r.user', 'u')
->leftJoin(City::class, 'c', 'WITH', 'c.id = r.cityId')
->orderBy('r.createdAt', 'DESC');
if ($search !== '') {
@@ -1025,7 +1024,7 @@ class AdminApiController extends BaseController
}
if ($cityId !== null && $cityId !== '') {
$qb->andWhere('r.cityId = :cityId')
$qb->andWhere(':cityId MEMBER OF r.cities')
->setParameter('cityId', (int) $cityId);
}
@@ -1034,19 +1033,41 @@ class AdminApiController extends BaseController
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
->getQuery()->getArrayResult();
$items = array_map(fn(array $r) => [
'id' => (int) $r['id'],
'uuid' => $r['uuid'],
'domain' => $r['fullName'],
'full_name' => $r['fullName'],
'mobile_number' => $r['mobileNumber'] ?: ($r['user_mobile'] ?? null),
'city_id' => $r['cityId'],
'city' => $r['city_name'] ?? null,
'commission_percent' => (float) $r['commissionPercent'],
'wallet_balance' => 0,
'is_active' => (bool) $r['active'],
'created_at' => date('c', (int) $r['createdAt']),
], $rows);
// نام شهرهای هر نماینده (چند-شهری) در یک کوئری برای صفحه‌ی جاری.
$repIds = array_map(fn(array $r) => (int) $r['id'], $rows);
$cityNames = [];
if ($repIds !== []) {
$cityRows = $this->em->getConnection()->fetchAllAssociative(
'SELECT rc.representation_id, c.id AS city_id, c.name
FROM representation_cities rc JOIN cities c ON c.id = rc.city_id
WHERE rc.representation_id IN (?) ORDER BY c.name',
[$repIds],
[\Doctrine\DBAL\ArrayParameterType::INTEGER],
);
foreach ($cityRows as $cr) {
$cityNames[(int) $cr['representation_id']][] = ['id' => (int) $cr['city_id'], 'name' => $cr['name']];
}
}
$items = array_map(function (array $r) use ($cityNames) {
$cities = $cityNames[(int) $r['id']] ?? [];
return [
'id' => (int) $r['id'],
'uuid' => $r['uuid'],
'domain' => $r['domain'],
'is_global' => (bool) $r['isGlobal'],
'full_name' => $r['fullName'],
'mobile_number' => $r['mobileNumber'] ?: ($r['user_mobile'] ?? null),
'city_id' => $cities[0]['id'] ?? $r['cityId'],
'city_ids' => array_column($cities, 'id'),
'cities' => $cities,
'city' => $cities !== [] ? implode('، ', array_column($cities, 'name')) : null,
'commission_percent' => (float) $r['commissionPercent'],
'wallet_balance' => 0,
'is_active' => (bool) $r['active'],
'created_at' => date('c', (int) $r['createdAt']),
];
}, $rows);
return $this->paginated($items, (int) $total, $page, $limit);
}
@@ -32,7 +32,7 @@ class AppointmentController extends BaseController
private readonly SlotCalculatorService $slotCalculator,
private readonly PatientService $patientService,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
) {}
// ── Public: available slots ───────────────────────────────────────────────
@@ -260,13 +260,11 @@ class AppointmentController extends BaseController
$appointment->setPatientGender($gender);
if (isset($data['note'])) $appointment->setNote($data['note']);
// نماینده‌ی دامنه‌ی جاری (city_id از سایت)؛ برای گاردِ پورسانت.
$cityId = (int) ($data['city_id'] ?? 0);
if ($cityId > 0) {
$bookingRep = $this->representationRepo->findActiveByCityId($cityId);
if ($bookingRep !== null) {
$appointment->setBookingRepresentationId($bookingRep->getId());
}
// نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی
// پرداخت دوباره از payment.frontend_address محاسبه می‌شود — این فقط ثبتِ لحظه‌ی رزرو است.
$bookingCtx = $this->domainResolver->resolve($request->headers->get('origin'));
if ($bookingCtx->representationId() !== null) {
$appointment->setBookingRepresentationId($bookingCtx->representationId());
}
// آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id).
+2 -1
View File
@@ -44,6 +44,7 @@ class ClinicController extends BaseController
private readonly UserRepository $userRepo,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly string $projectDir,
) {}
@@ -259,7 +260,7 @@ class ClinicController extends BaseController
#[Route('/api/v1/clinics', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$filters = $request->query->all();
$filters = $this->domainResolver->applyToListFilters($request->query->all());
$result = $this->clinicRepo->findWithFilters($filters);
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $result['items']);
@@ -65,6 +65,10 @@ class ClinicRepository extends ServiceEntityRepository
if (!empty($filters['specialty'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
}
// Scope دامنه‌ی نماینده‌ی سراسری (تزریق‌شده توسط DomainContextResolver در کنترلر).
if (!empty($filters['representation_id'])) {
$qb->andWhere('c.representationId = :repId')->setParameter('repId', (int) $filters['representation_id']);
}
$qb->orderBy('c.id', $sort);
+2 -1
View File
@@ -42,6 +42,7 @@ class DoctorController extends BaseController
private readonly FileValidatorService $fileValidator,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly TenantInsuranceCleanupService $insuranceCleanup,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly string $projectDir,
) {}
@@ -241,7 +242,7 @@ class DoctorController extends BaseController
#[Route('/api/v1/doctors', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$filters = $request->query->all();
$filters = $this->domainResolver->applyToListFilters($request->query->all());
$result = $this->doctorRepo->findWithFilters($filters);
$scheduleMap = [];
@@ -75,6 +75,10 @@ class DoctorRepository extends ServiceEntityRepository
if (!empty($filters['specialty_id'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
}
// Scope دامنه‌ی نماینده‌ی سراسری (تزریق‌شده توسط DomainContextResolver در کنترلر).
if (!empty($filters['representation_id'])) {
$qb->andWhere('d.representationId = :repId')->setParameter('repId', (int) $filters['representation_id']);
}
if (!empty($filters['gender'])) {
$qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']);
}
@@ -28,6 +28,11 @@ class CityRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult();
}
public function findByDomain(string $domain): ?City
{
return $this->findOneBy(['domain' => $domain]);
}
public function save(City $city, bool $flush = true): void
{
$this->getEntityManager()->persist($city);
+15 -3
View File
@@ -40,10 +40,20 @@ final class PaymentManager
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly CommissionService $commissionService,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly JalaliDateService $jalali,
private readonly string $appBaseUrl,
) {}
/**
* نماینده‌ی مالکِ دامنه‌ای که خرید از آن انجام شده مبنای کمیسیون دامنه‌محور.
* frontend_address هنگام initiate با allow-list دامنه‌ها validate شده است.
*/
private function bookingRepresentationIdFor(Payment $payment): ?int
{
return $this->domainResolver->resolve($payment->getFrontendAddress())->representationId();
}
/**
* درگاه را برای یک پرداخت pending init می‌کند (ارتباط با بانک).
* موفق PaymentInitResult؛ ناموفق false (پرداخت failed و ذخیره‌شده).
@@ -306,7 +316,7 @@ final class PaymentManager
$this->commissionService->processAppointment(
$payment,
$doctor->getRepresentationId(),
$appointment->getBookingRepresentationId(),
$this->bookingRepresentationIdFor($payment),
$doctor->getId(),
);
@@ -339,16 +349,18 @@ final class PaymentManager
$user = $payment->getUser();
$doctor = $this->doctorRepo->findByUser($user);
$bookingRepId = $this->bookingRepresentationIdFor($payment);
if ($doctor !== null) {
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $doctor->getId(), null);
return;
}
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null) {
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $clinic->getId());
}
}
@@ -4,6 +4,7 @@ namespace App\Representation\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Location\Repository\CityRepository;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
use App\Representation\Service\JalaliDateService;
@@ -25,10 +26,68 @@ class RepresentationController extends BaseController
public function __construct(
private readonly RepresentationRepository $representationRepo,
private readonly UserRepository $userRepo,
private readonly CityRepository $cityRepo,
private readonly EntityManagerInterface $em,
private readonly JalaliDateService $jalali,
) {}
/**
* اعمال فیلدهای مشترک create/update روی نماینده.
* خطا به‌صورت آرایه [code, message, field] برمی‌گردد؛ null یعنی موفق.
* فیلدهای domain و is_global فقط admin ($isAdmin) مجازند.
*/
private function applyRepresentationData(Representation $rep, array $data, bool $isAdmin): ?array
{
// چند-شهری: city_ids (آرایه) یا city_id قدیمی (BC).
$cityIds = null;
if (array_key_exists('city_ids', $data)) {
$cityIds = is_array($data['city_ids']) ? $data['city_ids'] : [];
} elseif (array_key_exists('city_id', $data)) {
$cityIds = $data['city_id'] ? [(int) $data['city_id']] : [];
}
if ($cityIds !== null) {
$cities = [];
foreach ($cityIds as $cid) {
$city = $this->cityRepo->find((int) $cid);
if ($city === null) {
return [ErrorCodes::ERR_VALIDATION_001, "شهر با شناسه {$cid} یافت نشد", 'city_ids'];
}
$cities[] = $city;
}
$rep->setCities($cities);
$rep->setCityId($cities !== [] ? (int) $cities[0]->getId() : null);
}
if (array_key_exists('domain', $data)) {
if (!$isAdmin) {
return [ErrorCodes::ERR_AUTH_006, 'تغییر دامنه فقط توسط مدیر مجاز است', 'domain'];
}
$domain = Representation::normalizeDomain(is_string($data['domain']) ? $data['domain'] : null);
if ($domain !== null) {
if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) {
return [ErrorCodes::ERR_VALIDATION_001, 'دامنه نامعتبر است', 'domain'];
}
if ($this->cityRepo->findByDomain($domain) !== null) {
return [ErrorCodes::ERR_CONFLICT_001, 'این دامنه متعلق به یکی از شهرهاست', 'domain'];
}
$existing = $this->representationRepo->findOneBy(['domain' => $domain]);
if ($existing !== null && $existing->getId() !== $rep->getId()) {
return [ErrorCodes::ERR_CONFLICT_001, 'این دامنه قبلاً برای نماینده دیگری ثبت شده است', 'domain'];
}
}
$rep->setDomain($domain);
}
if (array_key_exists('is_global', $data)) {
if (!$isAdmin) {
return [ErrorCodes::ERR_AUTH_006, 'تغییر نوع نماینده (سراسری) فقط توسط مدیر مجاز است', 'is_global'];
}
$rep->setIsGlobal((bool) $data['is_global']);
}
return null;
}
// ── CRUD ──────────────────────────────────────────────────────────────────
#[OA\Post(
@@ -115,10 +174,16 @@ class RepresentationController extends BaseController
}
$rep = new Representation($user, $fullName);
if (isset($data['city_id'])) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (!empty($data['commission_percent'])) $rep->setCommissionPercent((string)$data['commission_percent']);
if (!empty($data['bank_account'])) $rep->setBankAccount($data['bank_account']);
// create فقط توسط ادمین انجام می‌شود (IsGranted بالای متد) → isAdmin=true.
if (($err = $this->applyRepresentationData($rep, $data, true)) !== null) {
[$code, $message, $field] = $err;
$status = $code === ErrorCodes::ERR_CONFLICT_001 ? 409 : 422;
return $this->error($code, $message, $status, $field);
}
$this->representationRepo->save($rep);
return $this->success(['data' => $rep->toArray()], 201);
@@ -213,9 +278,18 @@ class RepresentationController extends BaseController
$isAdmin = $user->hasRole('ROLE_ADMIN');
if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']);
if (array_key_exists('city_id', $data)) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']);
if (($err = $this->applyRepresentationData($rep, $data, $isAdmin)) !== null) {
[$code, $message, $field] = $err;
$status = match ($code) {
ErrorCodes::ERR_CONFLICT_001 => 409,
ErrorCodes::ERR_AUTH_006 => 403,
default => 422,
};
return $this->error($code, $message, $status, $field);
}
// commission_percent and active are privileged: a representative must not
// be able to raise their own commission or activate themselves.
if (array_key_exists('commission_percent', $data)) {
@@ -0,0 +1,58 @@
<?php
namespace App\Representation\Controller;
use App\Representation\Service\DomainContextResolver;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* زمینه‌ی عمومی یک دامنه برای سایت nobat724: شهر است، دامنه‌ی اختصاصی نماینده است، یا ناشناخته.
* سایت عمومی برای دامنه‌های خارج از data/city.json از این endpoint استفاده می‌کند.
*/
#[OA\Tag(name: 'Representations')]
class SiteContextController extends BaseController
{
public function __construct(
private readonly DomainContextResolver $resolver,
) {}
#[OA\Get(
path: '/api/v1/site-context',
summary: 'Resolve a domain to its site context (city | representation | unknown)',
parameters: [
new OA\Parameter(name: 'domain', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Domain context'),
]
)]
#[Route('/api/v1/site-context', methods: ['GET'])]
public function resolve(Request $request): JsonResponse
{
$ctx = $this->resolver->resolve((string) $request->query->get('domain', ''));
$type = 'unknown';
if ($ctx->city !== null) {
$type = 'city';
} elseif ($ctx->representation !== null) {
$type = 'representation';
}
return $this->success([
'type' => $type,
'city' => $ctx->city === null ? null : [
'id' => $ctx->city->getId(),
'name' => $ctx->city->getName(),
],
'representation' => $ctx->representation === null ? null : [
'uuid' => $ctx->representation->getUuid(),
'full_name' => $ctx->representation->getFullName(),
'is_global' => $ctx->representation->isGlobal(),
],
]);
}
}
+56 -2
View File
@@ -3,6 +3,9 @@
namespace App\Representation\Entity;
use App\Auth\Entity\User;
use App\Location\Entity\City;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use App\Representation\Repository\RepresentationRepository;
use Symfony\Component\Uid\Uuid;
@@ -29,9 +32,22 @@ class Representation
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $mobileNumber = null;
/** @deprecated نگاشت تک-شهری قدیمی؛ منبع حقیقت $cities است. فقط برای BC خوانده می‌شود. */
#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)]
private ?int $cityId = null;
/** @var Collection<int, City> شهرهای تحت پوشش نماینده */
#[ORM\ManyToMany(targetEntity: City::class)]
#[ORM\JoinTable(name: 'representation_cities')]
private Collection $cities;
/** دامنه اختصاصی نماینده (نرمال‌شده: بدون scheme/www)؛ مبنای کمیسیون دامنه‌محور. */
#[ORM\Column(type: 'string', length: 255, nullable: true, unique: true)]
private ?string $domain = null;
#[ORM\Column(name: 'is_global', type: 'boolean')]
private bool $isGlobal = false;
#[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)]
private string $commissionPercent = '10.00';
@@ -56,16 +72,35 @@ class Representation
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->fullName = $fullName;
$this->cities = new ArrayCollection();
$this->createdAt = time();
$this->updatedAt = time();
}
/** نرمال‌سازی دامنه: lowercase، حذف scheme/www/پورت/اسلش انتهایی. */
public static function normalizeDomain(?string $domain): ?string
{
if ($domain === null) return null;
$d = strtolower(trim($domain));
$d = preg_replace('~^https?://~', '', $d) ?? $d;
$d = preg_replace('~^www\.~', '', $d) ?? $d;
$d = explode('/', $d)[0];
$d = explode(':', $d)[0];
return $d === '' ? null : $d;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getFullName(): string { return $this->fullName; }
public function getMobileNumber(): ?string { return $this->mobileNumber; }
public function getCityId(): ?int { return $this->cityId; }
public function getCityId(): ?int { return $this->cityIds()[0] ?? $this->cityId; }
/** @return Collection<int, City> */
public function getCities(): Collection { return $this->cities; }
/** @return int[] */
public function cityIds(): array { return array_values(array_map(fn(City $c) => (int) $c->getId(), $this->cities->toArray())); }
public function getDomain(): ?string { return $this->domain; }
public function isGlobal(): bool { return $this->isGlobal; }
public function getCommissionPercent(): string { return $this->commissionPercent; }
public function getBankAccount(): ?array { return $this->bankAccount; }
public function isActive(): bool { return $this->active; }
@@ -73,6 +108,18 @@ class Representation
public function setFullName(string $v): self { $this->fullName = $v; $this->touch(); return $this; }
public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; }
public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; }
/** @param City[] $cities */
public function setCities(array $cities): self
{
$this->cities->clear();
foreach ($cities as $city) {
if (!$this->cities->contains($city)) $this->cities->add($city);
}
$this->touch();
return $this;
}
public function setDomain(?string $v): self { $this->domain = self::normalizeDomain($v); $this->touch(); return $this; }
public function setIsGlobal(bool $v): self { $this->isGlobal = $v; $this->touch(); return $this; }
public function setCommissionPercent(string $v): self { $this->commissionPercent = $v; $this->touch(); return $this; }
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
@@ -132,7 +179,14 @@ class Representation
'uuid' => $this->uuid,
'full_name' => $this->fullName,
'mobile_number' => $this->mobileNumber,
'city_id' => $this->cityId,
'city_id' => $this->getCityId(),
'city_ids' => $this->cityIds(),
'cities' => array_values(array_map(
fn(City $c) => ['id' => (int) $c->getId(), 'name' => $c->getName()],
$this->cities->toArray(),
)),
'domain' => $this->domain,
'is_global' => $this->isGlobal,
'commission_percent' => $this->commissionPercent,
'bank_account' => $this->bankAccount,
'active' => $this->active,
@@ -24,9 +24,9 @@ class RepresentationRepository extends ServiceEntityRepository
return $this->findOneBy(['user' => $user]);
}
public function findActiveByCityId(int $cityId): ?Representation
public function findActiveByDomain(string $domain): ?Representation
{
return $this->findOneBy(['cityId' => $cityId, 'active' => true]);
return $this->findOneBy(['domain' => $domain, 'active' => true]);
}
public function save(Representation $entity, bool $flush = true): void
@@ -0,0 +1,26 @@
<?php
namespace App\Representation\Service;
use App\Location\Entity\City;
use App\Representation\Entity\Representation;
/** نتیجه‌ی نگاشت دامنه‌ی درخواست به زمینه‌ی سایت (شهر یا نماینده). */
final class DomainContext
{
public function __construct(
public readonly ?Representation $representation,
public readonly ?City $city,
public readonly bool $isGlobalRepresentation,
) {}
public static function empty(): self
{
return new self(null, null, false);
}
public function representationId(): ?int
{
return $this->representation?->getId();
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Representation\Service;
use App\Location\Repository\CityRepository;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
/**
* تنها نقطه‌ی نگاشت host context در backend. هیچ سرویس/کنترلر دیگری نباید
* مستقیماً دامنه را parse یا با cities/representations تطبیق دهد.
*
* ورودی هر شکلی از آدرس را می‌پذیرد (host خالص، URL کامل، با www/پورت).
*/
class DomainContextResolver
{
public function __construct(
private readonly CityRepository $cityRepo,
private readonly RepresentationRepository $representationRepo,
) {}
/**
* اعمال scope دامنه روی فیلترهای لیست‌های عمومی (doctors/clinics):
* دامنه‌ی نماینده‌ی سراسری فقط ردیف‌های همان نماینده؛ فیلتر شهر/استان بی‌اثر.
* دامنه‌ی شهری/ناشناخته فیلترها دست‌نخورده (فقط کلید domain حذف می‌شود).
*/
public function applyToListFilters(array $filters): array
{
$domain = $filters['domain'] ?? null;
unset($filters['domain']);
if (!is_string($domain) || $domain === '') {
return $filters;
}
$ctx = $this->resolve($domain);
if ($ctx->isGlobalRepresentation) {
$filters['representation_id'] = $ctx->representationId();
unset($filters['city_id'], $filters['state_id'], $filters['city'], $filters['state']);
}
return $filters;
}
public function resolve(?string $hostOrUrl): DomainContext
{
$domain = Representation::normalizeDomain($hostOrUrl);
if ($domain === null) {
return DomainContext::empty();
}
$city = $this->cityRepo->findByDomain($domain);
if ($city !== null) {
// دامنه‌ی شهری؛ نماینده‌ای که دامنه‌ی اختصاصی‌اش این باشد وجود ندارد،
// ولی ممکن است نماینده‌ای دامنه‌ی شهر را به‌عنوان دامنه‌ی خودش ثبت کرده باشد.
$rep = $this->representationRepo->findActiveByDomain($domain);
return new DomainContext($rep, $city, false);
}
$rep = $this->representationRepo->findActiveByDomain($domain);
if ($rep !== null) {
return new DomainContext($rep, null, $rep->isGlobal());
}
return DomainContext::empty();
}
}
+8 -3
View File
@@ -52,12 +52,17 @@ class CommissionService
);
}
/** پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent. */
public function processSubscription(Payment $payment, ?int $representationId, ?int $doctorId, ?int $clinicId): void
/**
* پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent.
* گاردِ دامنه (مثل نوبت): مالک پزشک/کلینیک و نماینده‌ی دامنه‌ی خرید باید یکی باشند.
*/
public function processSubscription(Payment $payment, ?int $ownerRepId, ?int $bookingRepId, ?int $doctorId, ?int $clinicId): void
{
if ($this->configRepo->get('upgrade_commission_enabled') !== '1') return;
$rep = $this->resolveRep($representationId);
if ($ownerRepId === null || $bookingRepId === null || $ownerRepId !== $bookingRepId) return;
$rep = $this->resolveRep($ownerRepId);
if ($rep === null) return;
$this->settle(
@@ -0,0 +1,128 @@
<?php
namespace App\Tests\Representation;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Representation\Entity\Representation;
use App\Representation\Service\DomainContextResolver;
use App\Settlement\Repository\FinancialBreakdownRepository;
use App\Settlement\Service\CommissionService;
use App\Tests\ApiTestCase;
/**
* قانون کمیسیون دامنه‌محور: کمیسیون فقط وقتی ثبت می‌شود که خرید از دامنه‌ی نماینده
* انجام شده باشد و پزشک/کلینیک هم متعلق به همان نماینده باشد. DomainContextResolver
* تنها نقطه‌ی نگاشت دامنه است.
*/
class DomainCommissionTest extends ApiTestCase
{
private function makeRep(?string $domain = null, bool $global = false, bool $active = true): Representation
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
$rep = new Representation($owner, 'نماینده دامنه');
$rep->setCommissionPercent('10');
if ($domain !== null) $rep->setDomain($domain);
$rep->setIsGlobal($global);
$rep->setActive($active);
$this->em->persist($rep);
$this->em->flush();
return $rep;
}
private function makePayment(string $frontendAddress): Payment
{
$payment = new Payment($this->createUser(), 2_000_000, 'mock', Payment::TYPE_APPOINTMENT, $frontendAddress);
$this->em->persist($payment);
$this->em->flush();
return $payment;
}
private function resolver(): DomainContextResolver
{
return static::getContainer()->get(DomainContextResolver::class);
}
// ── DomainContextResolver ────────────────────────────────────────────────
public function testResolverMatchesRepresentationDomainWithNormalization(): void
{
$domain = 'rep-' . uniqid() . '.ir';
$rep = $this->makeRep($domain, global: true);
$ctx = $this->resolver()->resolve('https://www.' . $domain . ':443/payment/result');
$this->assertSame($rep->getId(), $ctx->representationId());
$this->assertTrue($ctx->isGlobalRepresentation);
$this->assertNull($ctx->city);
}
public function testResolverIgnoresInactiveRepresentation(): void
{
$domain = 'rep-' . uniqid() . '.ir';
$this->makeRep($domain, active: false);
$ctx = $this->resolver()->resolve($domain);
$this->assertNull($ctx->representation);
$this->assertFalse($ctx->isGlobalRepresentation);
}
public function testResolverUnknownDomainIsEmpty(): void
{
$ctx = $this->resolver()->resolve('nowhere-' . uniqid() . '.ir');
$this->assertNull($ctx->representation);
$this->assertNull($ctx->city);
}
// ── CommissionService: گارد دوشرطی ───────────────────────────────────────
public function testAppointmentCommissionOnlyWhenDomainOwnerMatchesDoctorOwner(): void
{
static::getContainer()->get(SiteConfigRepository::class)->set('appointment_commission_enabled', '1');
$commission = static::getContainer()->get(CommissionService::class);
$breakdowns = static::getContainer()->get(FinancialBreakdownRepository::class);
$rep = $this->makeRep('rep-' . uniqid() . '.ir');
$other = $this->makeRep('rep-' . uniqid() . '.ir');
// دامنه‌ی نماینده‌ی دیگر → بدون کمیسیون
$p1 = $this->makePayment('https://' . $other->getDomain() . '/payment/result');
$commission->processAppointment($p1, $rep->getId(), $other->getId(), 7);
$this->assertFalse($breakdowns->existsForPayment($p1));
// بدون دامنه (پرداخت بدون frontend_address) → بدون کمیسیون
$p2 = $this->makePayment('');
$commission->processAppointment($p2, $rep->getId(), null, 7);
$this->assertFalse($breakdowns->existsForPayment($p2));
// دامنه و مالک یکی → کمیسیون ثبت می‌شود
$p3 = $this->makePayment('https://' . $rep->getDomain() . '/payment/result');
$commission->processAppointment($p3, $rep->getId(), $rep->getId(), 7);
$this->assertTrue($breakdowns->existsForPayment($p3));
}
public function testSubscriptionCommissionUsesSameDomainGuard(): void
{
$config = static::getContainer()->get(SiteConfigRepository::class);
$config->set('upgrade_commission_enabled', '1');
$config->set('upgrade_commission_percent', '20');
$commission = static::getContainer()->get(CommissionService::class);
$breakdowns = static::getContainer()->get(FinancialBreakdownRepository::class);
$rep = $this->makeRep('rep-' . uniqid() . '.ir');
// نماینده‌ی دامنه معلوم نیست → بدون کمیسیون (رفتار قدیمی کمیسیون می‌داد)
$p1 = $this->makePayment('');
$commission->processSubscription($p1, $rep->getId(), null, 5, null);
$this->assertFalse($breakdowns->existsForPayment($p1));
// دامنه و مالک یکی → کمیسیون
$p2 = $this->makePayment('https://' . $rep->getDomain() . '/panel');
$commission->processSubscription($p2, $rep->getId(), $rep->getId(), 5, null);
$this->assertTrue($breakdowns->existsForPayment($p2));
}
}