feat: add SeedDemoDataCommand for seeding production-like demo data for testing representations, doctors, clinics, users, appointments, and commissions

This commit is contained in:
hamed
2026-07-09 08:07:41 +03:30
parent 0750bc9812
commit 0ad8487aac
16 changed files with 3454 additions and 1155 deletions
+145
View File
@@ -0,0 +1,145 @@
# Seeder داده تست حجیم (شبیه Production) — نمایندگان، پزشکان، کلینیک‌ها، نوبت‌ها، کمیسیون
## زمینه
ماژول نمایندگان تازه چند-شهری و دامنه‌محور شده (`representation_cities`، `representations.domain`، `is_global`، کمیسیون فقط با تطابق دامنه+مالکیت — پیاده‌شده در prompt `representation-multi-city-domain-commission.md`). برای تست واقعی سیستم با حجم بالا، دیتای production-like لازم است. الان فقط `app:seed-categories` (شهر/استان/تخصص) و `create_test_users.php` (چند کاربر) وجود دارد — هیچ seeder حجیمی نیست.
## مشکل / هدف
یک Command جدید `app:seed-demo-data` که در یک اجرا بسازد:
| موجودیت | حداقل | نکته |
|---|---|---|
| نماینده شهری | ۲۰ | چند-شهری (۱–۳ شهر از جدول cities)، دامنه = دامنه‌ی شهر اصلی‌اش (مثل `yasuj-nobat.ir`) |
| نماینده سراسری | ۵ | `is_global=true`، دامنه اختصاصی (`x-nobat.ir`, `global-doctor.ir`, `iran-doc.ir`, `salamat-nobat.ir`, `doc24.ir`) |
| پزشک | ۵۰۰ | نام واقعی فارسی، تخصص از ۱۰ تخصص، نظام‌پزشکی یکتا، `representation_id` ست‌شده، آدرس با شهر |
| کلینیک | ۲۰۰ | نام/آدرس/شهر، `representation_id`، هر کدام ۱–۵ پزشک مرتبط |
| کاربر (بیمار) | ۱۰٬۰۰۰ | موبایل یکتای `0912xxxxxxx` |
| برنامه هفتگی | برای هر پزشک | روزها/شیفت صبح‌وعصر/مدت نوبت متفاوت (۱۵/۲۰/۳۰ دقیقه) |
| نوبت | ۲۰٬۰۰۰ | mix وضعیت‌ها + پرداخت + کمیسیون (پایین) |
| اشتراک | ~۱۰۰ | خرید اشتراک پزشک/کلینیک با پرداخت موفق + کمیسیون دامنه‌محور |
**Mix نوبت‌ها (قراردادِ سناریو — عمداً برای تست کمیسیون):**
- ۶۰٪ پرداخت‌شده و confirmed از دامنه‌ی نماینده‌ی مالکِ همان پزشک → **کمیسیون ثبت می‌شود**
- ۱۵٪ پرداخت‌شده از دامنه‌ی نماینده‌ی دیگر (mismatch) → **کمیسیون ثبت نمی‌شود**
- ۱۵٪ لغوشده (cancelled)
- ۱۰٪ آزاد/pending بدون پرداخت
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Shared/Command/SeedDemoDataCommand.php` | **جدید** — کل seeder |
| `src/Category/Command/SeedCategoriesCommand.php` | الگوی Command موجود (`#[AsCommand]`, SymfonyStyle) |
| `src/Representation/Entity/Representation.php` | `setCities([City])`, `setDomain()`, `setIsGlobal()`, `setCommissionPercent()` |
| `src/Doctor/Entity/Doctor.php` + `DoctorAddress` | فیلدها/ctor را بخوان — `representationId`, تخصص‌ها ManyToMany |
| `src/Clinic/Entity/Clinic.php` | `representationId`, رابطه پزشکان |
| `src/Appointment/Entity/Appointment.php` | ctor `(Doctor, User, slotStart, slotEnd)` + ثابت‌های STATUS + `setBookingRepresentationId` |
| `src/Payment/Entity/Payment.php` | ctor واقعی: `(User $user, int $amountRials, string $gateway, string $type, string $frontendAddress = '')` |
| `src/Settlement/Service/CommissionService.php` | `processAppointment(Payment, ?int $ownerRepId, ?int $bookingRepId, ?int $doctorId)` و `processSubscription(..., $ownerRepId, $bookingRepId, ...)` — گارد دوشرطی |
| جدول‌های `cities`, `specialties` | منبع شهر/تخصص (seed شده با `app:seed-categories`) |
| `create_test_users.php` | الگوی ساخت کاربر تستی |
## وضعیت فعلی
هیچ seeder داده‌ای وجود ندارد. الگوی Command موجود (کپی از `SeedCategoriesCommand`):
```php
#[AsCommand(name: 'app:seed-categories', description: '...')]
class SeedCategoriesCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// ...
$io->success(sprintf('%s: %d رکورد seed شد', $bundle, count($normalized)));
return Command::SUCCESS;
}
}
```
فرمول کمیسیون (از `CommissionService::settle` — seeder باید یا خودِ سرویس را صدا بزند یا دقیقاً همین فرمول را برای درج مستقیم استفاده کند):
```php
$afterSms = max(0, $gross - $smsFee);
$taxRials = tax_enabled ? round($afterSms * p / (100 + p)) : 0;
$netAfterTax = $afterSms - $taxRials;
$repShare = round($netAfterTax * commissionPercent / 100);
```
## وظایف
### ۰. تحلیل پیش از کد (الزامی)
Entityهای `Doctor`, `DoctorAddress`, `Clinic`, `Appointment` (ثابت‌های STATUS)، `WeeklySchedule` (ساختار سشن‌ها/`meta.online_booking_enabled``ClinicSubscription` و repository هایشان را بخوان و در گزارش طراحی، ctor/فیلدهای لازم هر کدام را فهرست کن. حجم بالا → استراتژی درج را همان‌جا قطعی کن (پایین).
### ۱. Command `app:seed-demo-data`
```php
#[AsCommand(name: 'app:seed-demo-data', description: 'Seed production-like demo data (reps, doctors, clinics, users, appointments, commissions)')]
```
- گزینه‌ها: `--doctors=500 --clinics=200 --users=10000 --appointments=20000 --purge --force`
- **گارد محیط:** اگر `APP_ENV === 'prod'` و `--force` نبود → abort با پیام فارسی.
- `--purge`: حذف داده‌ی قبلی demo به ترتیب FK-safe (breakdowns → wallet_transactions → payments → appointments → schedules → clinic_doctors → clinics → doctor_addresses → doctors → representation_cities → representations → users غیرادمین). فقط رکوردهای demo (با marker — پایین) پاک شوند، نه کاربر ادمین/داده واقعی.
- **Marker داده demo:** موبایل کاربران demo با پیشوند `0912000`/`0912999` یا email الگودار — روش قطعی purge را خودت انتخاب و مستند کن.
### ۲. استراتژی درج (performance)
- کاربران/نوبت‌ها/پرداخت‌ها (ده‌ها هزار رکورد): **DBAL bulk INSERT** با chunkهای ۵۰۰تایی (`INSERT INTO ... VALUES (...), (...), ...`) — نه ORM per-row. uuid با `Uuid::v4()` در PHP.
- نمایندگان/پزشکان/کلینیک‌ها (صدها رکورد): ORM با `flush()` هر ۵۰ رکورد + `em->clear()`.
- کل اجرا باید زیر ~۲ دقیقه روی ddev باشد؛ progress bar (`$io->progressStart`).
### ۳. نمایندگان
- ۲۰ شهری: نام واقعی فارسی متنوع (آرایه ثابت ۲۰ نام)، `commission_percent` بین ۸ تا ۱۵، هر کدام ۱–۳ شهر (گروه‌بندی جغرافیایی منطقی از جدول cities)، `domain` = دامنه‌ی شهر اصلی (ستون `cities.domain`).
- نکته: کنترلر ادمین دامنه‌ی شهری را برای rep قبول نمی‌کند (conflict) ولی seeder مستقیم entity می‌سازد و `DomainContextResolver` این حالت را پشتیبانی می‌کند (city-first، سپس rep همان دامنه) — عمداً همین مدل واقعی را بساز.
- ۵ سراسری: `is_global=true` + دامنه‌های اختصاصی بالا؛ بدون شهر یا با شهرهای پراکنده.
- هر نماینده یک User با `ROLE_REPRESENTATION` (الگوی `RepresentationController::create`).
### ۴. پزشکان + کلینیک‌ها + برنامه هفتگی
- ۵۰۰ پزشک: ترکیب نام/نام‌خانوادگی فارسی از دو آرایه (۲۵×۲۵)، تخصص از ۱۰ تخصص خواسته‌شده (match با نام در جدول specialties؛ اگر نبود از موجودها استفاده کن و در گزارش ذکر کن)، `medical_system_code` یکتا (`100000+i`)، ~۸۵٪ فعال، `representation_id` وزن‌دار (نماینده‌های شهری بیشتر؛ ۵ سراسری هر کدام ۱۵–۳۰ پزشک؛ ~۱۰٪ پزشک بدون نماینده برای سناریوی بدون کمیسیون)، آدرس در یکی از شهرهای نماینده‌اش.
- ۲۰۰ کلینیک: نام «کلینیک X شهر»، آدرس، شهر، `representation_id` هم‌راستا، اتصال ۱–۵ پزشکِ همان نماینده.
- برنامه هفتگی هر پزشک فعال: ۳–۶ روز کاری، شیفت صبح (۸–۱۴) و/یا عصر (۱۶–۲۱)، مدت نوبت ۱۵/۲۰/۳۰ — دقیقاً با ساختار واقعی `WeeklySchedule` موجود (بعد از تحلیل مرحله ۰).
### ۵. نوبت‌ها + پرداخت‌ها + کمیسیون (هسته تست)
برای ۲۰٬۰۰۰ نوبت در بازه‌ی ۶۰ روز گذشته تا ۱۴ روز آینده، هم‌راستا با برنامه هفتگی پزشک (slotStart روی مرز مدت نوبت):
- **۶۰٪ match:** کاربر تصادفی، `frontendAddress = 'https://' . <دامنه‌ی نماینده‌ی مالک پزشک> . '/payment/result'`، Payment با status success + `reference_id` یکتا، Appointment وضعیت confirmed (یا done برای گذشته)، `bookingRepresentationId` = همان نماینده.
- **۱۵٪ mismatch:** همان ساختار ولی frontendAddress از دامنه‌ی نماینده‌ی دیگر → کمیسیون نباید ثبت شود.
- **۱۵٪ cancelled** و **۱۰٪ pending** بدون پرداخت.
- **کمیسیون:** برای درج حجیم، ردیف‌های `financial_breakdowns` + `wallet_transactions` را مستقیم با همان فرمول `CommissionService::settle` bulk-insert کن (فقط برای match ها)؛ **علاوه بر آن ۱۰۰ نوبت آخر را از مسیر واقعی `CommissionService->processAppointment()` رد کن** تا parity فرمول تضمین شود (اگر اختلاف شمارش/مبلغ دیدی، فرمول bulk را اصلاح کن).
- اشتراک: ~۱۰۰ خرید اشتراک (نیمی پزشک، نیمی کلینیک) با Payment موفق و همان قاعده دامنه — نیمی match (کمیسیون با `upgrade_commission_percent`) و نیمی mismatch.
- تنظیمات لازم را خود seeder ست کند: `appointment_commission_enabled=1`, `upgrade_commission_enabled=1`, `upgrade_commission_percent=20` (via `SiteConfigRepository::set`).
### ۶. گزارش پایانی + سناریوهای وریفای
در انتهای اجرا جدول شمارش چاپ کن (SELECT COUNT از هر جدول) و این کوئری‌های وریفای را هم اجرا و نمایش بده:
```sql
-- کمیسیون فقط برای matchها
SELECT COUNT(*) FROM financial_breakdowns; -- باید ≈ تعداد matchهای پرداخت‌شده + اشتراک‌های match
-- هیچ breakdown ای برای پرداخت‌های mismatch (فلگ scenario را در payment.metadata ذخیره کن تا این کوئری ممکن شود)
-- مجموع سهم نماینده == مجموع wallet_transactions credit
SELECT (SELECT COALESCE(SUM(representation_share_rials),0) FROM financial_breakdowns)
= (SELECT COALESCE(SUM(amount_rials),0) FROM wallet_transactions WHERE type='credit');
```
و ۵ سناریوی تست دستی در خروجی چاپ کن (متن فارسی):
1. `GET /api/v1/doctors?domain=x-nobat.ir` → فقط پزشکان نماینده سراسری اول.
2. `GET /api/v1/doctors?domain=yasuj-nobat.ir` → رفتار شهری عادی (همه پزشکان شهر).
3. `GET /api/v1/site-context?domain=x-nobat.ir``type=representation, is_global=true`.
4. داشبورد نماینده یاسوج (`/api/v1/representation/dashboard/summary` با کاربر همان نماینده) → درآمد > 0.
5. پنل ادمین → نمایندگان → badge «سراسری» روی ۵ نماینده + ستون شهرها چندتایی.
## نکات مهم
- **هیچ Entity/schema تغییری لازم نیست** — فقط Command جدید + استفاده از سرویس/entityهای موجود؛ پس migration و docs/api لازم ندارد (Command داخلی است، API نیست).
- تصادفی‌بودن **seeded** باشد (`mt_srand(42)`) تا اجراها تکرارپذیر باشند.
- `payment.metadata` هر پرداخت demo شامل `{"demo": true, "scenario": "match|mismatch|..."}` — هم برای purge هم برای کوئری‌های وریفای.
- ترتیب درج FK-safe؛ `SET FOREIGN_KEY_CHECKS` دستکاری نشود (برخلاف importer) — ترتیب درست کافی است.
- موبایل‌ها یکتا و در رنج رزرو demo؛ با `create_test_users.php` (ادمین واقعی تست) تداخل نکند.
- تست: `ddev exec php bin/console app:seed-demo-data --purge` دوبار پشت‌سرهم باید بدون خطا اجرا شود (idempotent با purge). `php -l`، phpstan، و اجرای کامل با شمارش‌های انتظاری در گزارش.
- زمان‌ها Unix timestamp صحیح (الگوی پروژه)؛ نوبت‌های گذشته/آینده نسبت به `time()`.
+4
View File
@@ -24,6 +24,10 @@ services:
arguments:
$projectDir: '%kernel.project_dir%'
App\Shared\Command\SeedDemoDataCommand:
arguments:
$environment: '%kernel.environment%'
App\Doctor\Controller\DoctorController:
arguments:
$projectDir: '%kernel.project_dir%'
-1
View File
@@ -735,6 +735,5 @@
"733": "Community 733",
"734": "Community 734",
"735": "Community 735",
"736": "Community 736",
"738": "Community 738"
}
+142 -151
View File
@@ -1,16 +1,16 @@
# Graph Report - clinicpro (2026-07-09)
## Corpus Check
- 726 files · ~532,146 words
- 728 files · ~537,442 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 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)
- 9196 nodes · 12725 edges · 737 communities (586 shown, 151 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 278 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `59559e2e`
- Built from commit: `0750bc98`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -736,7 +736,6 @@
- [[_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)
@@ -756,8 +755,8 @@
assets/admin/components/ServiceTariffModal.tsx → assets/admin/lib/utils.ts
- `Pagination()` --calls--> `formatNumber()` [EXTRACTED]
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
- `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]
@@ -766,7 +765,7 @@
## Import Cycles
- None detected.
## Communities (738 total, 150 thin omitted)
## Communities (737 total, 151 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
@@ -785,8 +784,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.05
Nodes (11): DoctorServiceController, DoctorService, Doctor, DoctorServiceRepository, Collection, self, User, WeeklySchedule (+3 more)
Cohesion: 0.08
Nodes (5): Doctor, Collection, self, User, WeeklySchedule
### Community 5 - "Community 5"
Cohesion: 0.07
@@ -821,40 +820,40 @@ Cohesion: 0.05
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.08
Nodes (20): get, api, ApiError, getToken(), refreshOnce(), request(), { refreshMock, logoutMock }, replaceMock (+12 more)
Cohesion: 0.07
Nodes (25): get, api, ApiError, getToken(), refreshOnce(), request(), { refreshMock, logoutMock }, replaceMock (+17 more)
### Community 14 - "Community 14"
Cohesion: 0.15
Nodes (8): AppointmentSettingsController, Holiday, HolidayRepository, JsonResponse, Request, User, Doctor, ManagerRegistry
Cohesion: 0.10
Nodes (12): AppointmentSettingsController, DateOverride, Holiday, DateOverrideRepository, HolidayRepository, JsonResponse, Request, User (+4 more)
### Community 15 - "Community 15"
Cohesion: 0.25
Nodes (7): PaymentController, Appointment, JsonResponse, Payment, Request, Response, User
### Community 16 - "Community 16"
Cohesion: 0.07
Nodes (7): PatientSession, SmsWallet, Appointment, Collection, PatientRecord, self, SessionService
Cohesion: 0.06
Nodes (8): PatientSession, SmsWallet, LogPruneService, Appointment, Collection, PatientRecord, self, SessionService
### Community 17 - "Community 17"
Cohesion: 0.06
Nodes (33): DELETE `/api/v1/comment/{uuid}`, Errors, Errors, Errors, Errors, Errors, Errors, Errors (+25 more)
Cohesion: 0.05
Nodes (38): DELETE `/api/v1/comment/{uuid}`, Errors, Errors, Errors, Errors, Errors, Errors, Errors (+30 more)
### Community 18 - "Community 18"
Cohesion: 0.05
Nodes (38): API, API, API, API, API, Route, Route, Route (+30 more)
### Community 19 - "Community 19"
Cohesion: 0.04
Nodes (56): PaginatedResponse, formatDate(), STATUS_FILTERS, AddForm, addSchema, ClinicsPage(), HUES_LIST, FILTERS (+48 more)
Cohesion: 0.03
Nodes (81): PaginatedResponse, CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm (+73 more)
### Community 20 - "Community 20"
Cohesion: 0.13
Nodes (3): AdminApiController, JsonResponse, Request
### Community 21 - "Community 21"
Cohesion: 0.06
Nodes (29): formatNumber(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent, AdminStats, APPT_CLS, APPT_COLOR (+21 more)
Cohesion: 0.04
Nodes (59): PaymentConfig, PaymentGatewayInfo, usePaymentConfig(), ApiResponse, formatNumber(), formatRial(), ClinicDetailPage(), AdminCharts (+51 more)
### Community 22 - "Community 22"
Cohesion: 0.15
@@ -873,8 +872,8 @@ Cohesion: 0.05
Nodes (39): Appointment API, Error Responses, Errors, Errors, Errors, Errors, Errors, Errors (+31 more)
### Community 26 - "Community 26"
Cohesion: 0.15
Nodes (7): InsuranceController, EntityInsurancePricing, EntityInsurancePricingRepository, JsonResponse, Request, User, ManagerRegistry
Cohesion: 0.24
Nodes (4): InsuranceController, JsonResponse, Request, User
### Community 27 - "Community 27"
Cohesion: 0.05
@@ -928,10 +927,6 @@ Nodes (5): DoctorAddress, City, Doctor, Province, self
Cohesion: 0.09
Nodes (4): User, PasswordAuthenticatedUserInterface, self, UserInterface
### Community 41 - "Community 41"
Cohesion: 0.21
Nodes (5): DateOverrideOwnershipTest, DateOverride, DateOverrideRepository, Doctor, ManagerRegistry
### Community 42 - "Community 42"
Cohesion: 0.07
Nodes (29): API calls (با `useMutation`):, API موجود:, Frontend موجود:, `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/deactivate`, `PATCH /api/v1/clinic/{clinicUuid}/doctor/{doctorUuid}/reactivate`, UX نکات:, آپدیت `GET /api/v1/clinic/doctor-list/{clinicUuid}` (فیلدهای جدید), آپدیت interface: (+21 more)
@@ -977,8 +972,8 @@ Cohesion: 0.07
Nodes (26): الزامات UI, باگ‌فیکس صفحه نوبت‌ها, باگ ۱ — کرش تقویم, باگ ۲ — روز هفته در DateNavigator, باگ ۳ — پیام «slot نیست», باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient, باگ ۵ — patient_mobile نشان می‌دهد موبایل پزشک, باگ ۶ — نوبت‌های رزرو شده در نمایش زمانبندی (+18 more)
### Community 53 - "Community 53"
Cohesion: 0.08
Nodes (16): AppLogRepository, ClaimItemRepository, DoctorInsuranceRepository, InvoiceItemRepository, PreRegistrationRepository, TaxRateHistoryRepository, ServiceEntityRepository, ManagerRegistry (+8 more)
Cohesion: 0.10
Nodes (13): AppLogRepository, ClaimItemRepository, DoctorInsuranceRepository, InvoiceRepository, PreRegistrationRepository, ServiceEntityRepository, ManagerRegistry, ManagerRegistry (+5 more)
### Community 54 - "Community 54"
Cohesion: 0.10
@@ -1017,8 +1012,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.22
Nodes (5): AuthController, RateLimiterFactory, JsonResponse, Request, User
Cohesion: 0.09
Nodes (20): FreeVisitPrice(), Pricing, rialToToman(), tomanToRial(), IbanItem, RepMe, RepresentationSettlementPage(), RepSummary (+12 more)
### Community 64 - "Community 64"
Cohesion: 0.29
@@ -1045,8 +1040,8 @@ Cohesion: 0.15
Nodes (12): رفع بهم‌ریختگی کامل پنل ادمین روی iPhone 8 (Safari/Chrome iOS), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+4 more)
### Community 71 - "Community 71"
Cohesion: 0.20
Nodes (8): AdminUser, ChangeRoleModal(), getPrimaryRole(), HUES_LIST, ROLE_META, ROLE_TABS, RoleBadge(), UserStats
Cohesion: 0.09
Nodes (21): AddForm, addSchema, ClinicsPage(), HUES_LIST, EMPTY, PreRegistration, STATUS_META, STATUS_TABS (+13 more)
### Community 72 - "Community 72"
Cohesion: 0.09
@@ -1080,10 +1075,6 @@ Nodes (3): SubscriptionPlan, Collection, self
Cohesion: 0.09
Nodes (23): dependencies, @ckeditor/ckeditor5-build-classic, @ckeditor/ckeditor5-react, @fontsource/vazirmatn, @heroicons/react, @hookform/resolvers, jalaali-js, leaflet (+15 more)
### Community 82 - "Community 82"
Cohesion: 0.07
Nodes (8): CategoryImportTest, Connection, RepositoryClassMappingTest, KernelTestCase, DbLogger, CategoryImporter, DbLoggerTest, Stringable
### Community 83 - "Community 83"
Cohesion: 0.09
Nodes (21): Backend, CSS / UI, Frontend, روند اجرای هر قابلیت, قبل از شروع — تحلیل پرامپت و ساخت Todo, قوانین اجرا (اجباری — هیچ استثنایی ندارد), قوانین خاص این پروژه, مثال اجرا (+13 more)
@@ -1097,7 +1088,7 @@ 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.07
Cohesion: 0.06
Nodes (14): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, KernelBrowser, CommentPaginationTest (+6 more)
### Community 87 - "Community 87"
@@ -1133,8 +1124,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.08
Nodes (7): ClinicSubscription, Holiday, Doctor, self, Payment, SubscriptionPeriod, SubscriptionPlan
Cohesion: 0.15
Nodes (4): ClinicSubscription, Payment, SubscriptionPeriod, SubscriptionPlan
### Community 96 - "Community 96"
Cohesion: 0.18
@@ -1201,8 +1192,8 @@ Cohesion: 0.16
Nodes (3): DoctorService, self, Specialty
### Community 112 - "Community 112"
Cohesion: 0.06
Nodes (36): PaymentConfig, PaymentGatewayInfo, usePaymentConfig(), buildKavenegarPattern(), KavenegarGuide(), STATUS_LOG_META, Tab, TAG_FILTER_OPTIONS (+28 more)
Cohesion: 0.18
Nodes (3): Holiday, Doctor, self
### Community 113 - "Community 113"
Cohesion: 0.15
@@ -1285,8 +1276,8 @@ Cohesion: 0.12
Nodes (16): Endpoint ها, GET /api/v1/representation/filter/{id}, GET /api/v1/representation/filter/{representationId}, GET /api/v1/representation/my-appointments/{id}, GET /api/v1/representation/{uuid}, GET /api/v1/representation/yearly-income/{id}, GET /api/v1/representation/yearly-income/{representationId}, POST /api/v1/representations/{id}/bank-accounts (+8 more)
### Community 135 - "Community 135"
Cohesion: 0.12
Nodes (16): Day Index Convention, DELETE `/api/v1/booking-setting/{uuid}`, Errors, Errors, Errors, Errors, GET `/api/v1/appointment-settings/weekly-schedule/{uuid}`, PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}` (+8 more)
Cohesion: 0.09
Nodes (22): Appointment Settings API, Available Locations, Day Index Convention, DELETE `/api/v1/booking-setting/{uuid}`, Errors, Errors, Errors, Errors (+14 more)
### Community 136 - "Community 136"
Cohesion: 0.12
@@ -1417,8 +1408,8 @@ Cohesion: 0.10
Nodes (20): api.ir (استعلام هویت — Shahkar / IbanMatch), اتصال به دیتابیس‌های مستقل (الزامی), اسرار (الزامی — قبل از اولین دیپلوی), امنیت و منابع, بررسی سلامت, دامنه‌ها و CORS, دیپلوی‌های بعدی, راهنمای دیپلوی ClinicPro (Coolify + Docker Compose) (+12 more)
### Community 169 - "Community 169"
Cohesion: 0.32
Nodes (3): SmsService, SendSmsMessage, SmsProviderInterface
Cohesion: 0.15
Nodes (5): EntityInsurancePricing, TenantInsuranceCleanupTest, EntityInsurancePricingRepository, TenantInsuranceCleanupService, ManagerRegistry
### Community 170 - "Community 170"
Cohesion: 0.13
@@ -1441,8 +1432,8 @@ Cohesion: 0.14
Nodes (14): Doctor Management, Errors, Errors, GET `/api/v1/admin/doctors`, GET `/api/v1/admin/doctors/stats`, POST `/api/v1/admin/clinic`, POST `/api/v1/admin/doctors`, POST `/api/v1/admin/doctors/{uuid}/status` (+6 more)
### Community 175 - "Community 175"
Cohesion: 0.10
Nodes (13): Claim, ClaimItem, DebtRow, InsuranceOption, KIND_LABEL, STATUS_FILTERS, STATUS_META, IbanItem (+5 more)
Cohesion: 0.13
Nodes (14): Seeder داده تست حجیم (شبیه Production) — نمایندگان، پزشکان، کلینیک‌ها، نوبت‌ها، کمیسیون, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, ۰. تحلیل پیش از کد (الزامی) (+6 more)
### Community 176 - "Community 176"
Cohesion: 0.25
@@ -1538,7 +1529,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/secretaries`, GET /api/v1/admin/settings, GET `/api/v1/admin/settlements`, PATCH /api/v1/admin/settings, Query Parameters, Query Parameters (+5 more)
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)
### Community 202 - "Community 202"
Cohesion: 0.15
@@ -1553,16 +1544,16 @@ Cohesion: 0.21
Nodes (6): AuthenticationException, ExceptionSubscriber, SecurityHeadersSubscriber, EventSubscriberInterface, ExceptionEvent, ResponseEvent
### Community 205 - "Community 205"
Cohesion: 0.10
Nodes (16): Command, CancelExpiredAppointmentsCommand, CreateAdminCommand, PruneLogsCommand, SeedCategoriesCommand, SeedSmsMessageTemplatesCommand, InputInterface, OutputInterface (+8 more)
Cohesion: 0.07
Nodes (20): Command, CancelExpiredAppointmentsCommand, CreateAdminCommand, PruneLogsCommand, SeedCategoriesCommand, SeedDemoDataCommand, SeedSmsMessageTemplatesCommand, InputInterface (+12 more)
### Community 206 - "Community 206"
Cohesion: 0.08
Nodes (14): MellatGateway, MockGateway, SepGateway, SoapClient, PaymentGatewayInterface, PaymentInitResult, PaymentRefundResult, PaymentVerifyResult (+6 more)
### Community 207 - "Community 207"
Cohesion: 0.07
Nodes (30): ۲. مدل داده و موجودیت‌ها, ۲.۱ کاربر (User), ۲.۱۰ پرداخت (Payment Types), ۲.۱۰.۱ پرداخت نوبت, ۲.۱۰.۲ پرداخت اشتراک, ۲.۱۱ پروفایل بیمار (Profile), ۲.۱۲ وبلاگ (Blog), ۲.۱۳ نظرات، لایک و امتیازدهی (+22 more)
Cohesion: 0.15
Nodes (13): ۲. مدل داده و موجودیت‌ها, ۲.۱ کاربر (User), ۲.۱۰ پرداخت (Payment Types), ۲.۱۰.۱ پرداخت نوبت, ۲.۱۰.۲ پرداخت اشتراک, ۲.۱۱ پروفایل بیمار (Profile), ۲.۱۲ وبلاگ (Blog), ۲.۳ دکتر (Doctor) (+5 more)
### Community 208 - "Community 208"
Cohesion: 0.23
@@ -1617,8 +1608,8 @@ Cohesion: 0.23
Nodes (5): PaymentRepository, Appointment, ManagerRegistry, Payment, User
### Community 225 - "Community 225"
Cohesion: 0.07
Nodes (26): formatDateTime(), ALL_STATUSES, AppointmentDetailPage(), timeOf(), LogsPage(), SessionRow(), PAYMENT_TYPE_LABELS, PaymentDetailPage() (+18 more)
Cohesion: 0.05
Nodes (37): formatDate(), formatDateTime(), ALL_STATUSES, AppointmentDetailPage(), timeOf(), Claim, ClaimItem, DebtRow (+29 more)
### Community 226 - "Community 226"
Cohesion: 0.15
@@ -1693,8 +1684,8 @@ Cohesion: 0.28
Nodes (4): SubscriptionService, ClinicSubscription, Payment, SubscriptionPlan
### Community 245 - "Community 245"
Cohesion: 0.31
Nodes (6): ErrorCodes, ClinicServiceController, JsonResponse, Request, ServiceSection, User
Cohesion: 0.36
Nodes (3): DoctorServiceController, JsonResponse, Request
### Community 246 - "Community 246"
Cohesion: 0.17
@@ -1733,8 +1724,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.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)
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)
### Community 256 - "Community 256"
Cohesion: 0.29
@@ -1765,7 +1756,7 @@ Cohesion: 0.18
Nodes (10): زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, همگام‌سازی نام کاربر (User.real_name) هنگام تکمیل پروفایل, وضعیت فعلی (کد واقعی), وظایف, پروژه (+2 more)
### Community 264 - "Community 264"
Cohesion: 0.33
Cohesion: 0.30
Nodes (4): RepresentationRepository, ManagerRegistry, Representation, User
### Community 265 - "Community 265"
@@ -1805,7 +1796,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.20
Cohesion: 0.21
Nodes (5): Authentication, ClinicPro — API Documentation Index, Error Code Reference, Modules, Standard Response Envelope
### Community 275 - "Community 275"
@@ -1817,8 +1808,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.08
Nodes (23): GET /api/v1/sms/balance — موجودی حساب پیامک, POST /api/v1/sms/queue — افزودن به صف, بخش اول — مستند محصول (PRD), ثبت‌نام دکتر — از طریق نماینده, ثبت‌نام دکتر — از طریق کلینیک, ثبت‌نام دکتر — مستقل, فهرست کلی, کلینیک پرو — مستند جامع فنی و API (+15 more)
Cohesion: 0.20
Nodes (9): بخش اول — مستند محصول (PRD), فهرست کلی, کلینیک پرو — مستند جامع فنی و API, ۵. فهرست مشکلات شناسایی‌شده و اصلاحات لازم, ۶. پیوست, ۶.۱ دیاگرام وضعیت نوبت, ۶.۲ جریان کمیسیون, ۶.۳ بررسی محدودیت منشی (+1 more)
### Community 279 - "Community 279"
Cohesion: 0.20
@@ -1909,8 +1900,8 @@ Cohesion: 0.42
Nodes (3): PreRegistrationController, JsonResponse, Request
### Community 301 - "Community 301"
Cohesion: 0.04
Nodes (60): FreeVisitPrice(), Pricing, ApiResponse, cn(), formatRial(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile() (+52 more)
Cohesion: 0.06
Nodes (31): cn(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput(), toDate(), toEnglishDigits() (+23 more)
### Community 302 - "Community 302"
Cohesion: 0.12
@@ -1922,7 +1913,7 @@ Nodes (16): آماده‌سازی پروژه ClinicPro برای دیپلوی ر
### Community 304 - "Community 304"
Cohesion: 0.22
Nodes (9): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 36. 🟢 `GET` get my rate, 6. کلینیک (Clinic), هدرهای اضافی, پارامترهای Query, پاسخ‌ها, پاسخ‌ها (+1 more)
Nodes (9): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 6. کلینیک (Clinic), هدرهای اضافی, پارامترهای Query, پاسخ‌ها, پاسخ‌ها (+1 more)
### Community 306 - "Community 306"
Cohesion: 0.07
@@ -1969,8 +1960,8 @@ Cohesion: 0.31
Nodes (3): SubscriptionPlanRepository, ManagerRegistry, SubscriptionPlan
### Community 318 - "Community 318"
Cohesion: 0.43
Nodes (3): SmsMessageController, JsonResponse, Request
Cohesion: 0.22
Nodes (5): AuthController, RateLimiterFactory, JsonResponse, Request, User
### Community 319 - "Community 319"
Cohesion: 0.39
@@ -2021,8 +2012,8 @@ Cohesion: 0.22
Nodes (8): Query های جدید, بیماران منحصربه‌فرد در بازه, درآمد بر اساس روز (از patient_sessions), فروش اشتراک بر اساس پنل (admin), نوبت‌ها بر اساس روز (admin chart), نکات مهم, هیچ migration لازم نیست, پایگاه داده — تسک ۱۶: داشبورد هوشمند
### Community 333 - "Community 333"
Cohesion: 0.25
Nodes (7): DELETE `/api/v1/clinic-pro/doctor-address/{id}`, Doctor API, Errors, GET `/api/v1/doctors`, Query Parameters, Response `200`, Response `200`
Cohesion: 0.05
Nodes (40): DELETE `/api/v1/clinic-pro/doctor-address/{id}`, Doctor API, Errors, Errors, Errors, Errors, Errors, Errors (+32 more)
### Community 334 - "Community 334"
Cohesion: 0.25
@@ -2045,8 +2036,8 @@ Cohesion: 0.39
Nodes (5): JsonContains, FunctionNode, Node, Parser, SqlWalker
### Community 340 - "Community 340"
Cohesion: 0.35
Nodes (5): PatientController, JsonResponse, PatientSession, Request, User
Cohesion: 0.25
Nodes (8): ثبت‌نام دکتر — از طریق نماینده, ثبت‌نام دکتر — از طریق کلینیک, ثبت‌نام دکتر — مستقل, ۱. معرفی محصول, ۱.۱ نقش‌های سیستم, ۱.۲ فلوهای عملیاتی اصلی, ۱.۳ پلن‌های اشتراک, ۱.۴ سیستم پیامک
### Community 341 - "Community 341"
Cohesion: 0.12
@@ -2093,8 +2084,8 @@ Cohesion: 0.11
Nodes (18): `Appointment.php`, `PaymentController::pay` (نوبت را اصلاً چک نمی‌کند), `PaymentController::startOrderPayment` (فقط status، بدون چک زمان), `renderPaymentResult` labels (برچسب `expired` ندارد), الگوی موجود انقضا (`AppointmentExpiryService`) — برای مرجع, زمینه, فایل‌های مرتبط, مشکل / هدف (+10 more)
### Community 352 - "Community 352"
Cohesion: 0.06
Nodes (23): CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm, provinceSchema (+15 more)
Cohesion: 0.25
Nodes (8): ۲.۲ انواع دسته‌بندی (Category Types), ۲.۲.۱ تگ (Tag), ۲.۲.۲ استان (State), ۲.۲.۳ شهر (City), ۲.۲.۴ بیمه پایه (Basic Insurance), ۲.۲.۵ بیمه مکمل (Supplementary Insurance), ۲.۲.۶ تخصص دکتر (Doctor Specialty), ۲.۲.۷ خدمات دکتر (Doctor Services)
### Community 354 - "Community 354"
Cohesion: 0.36
@@ -2197,8 +2188,8 @@ Cohesion: 0.29
Nodes (7): بک‌اند — endpoint جدید: `GET /api/v1/my/appointments` `[IS_AUTHENTICATED_FULLY]`, فرانت‌اند — `AppointmentsPage.tsx`, مرحله ۸ — نوبت‌های فیلترشده (بک‌اند + فرانت‌اند) — API جدید, نمای جدولی (`TableView`):, نمای زمانبندی (`TimelineView`):, هدر صفحه (مشترک هر دو نما):, وضعیت‌های نوبت (باید در entity و frontend هر دو باشند):
### Community 380 - "Community 380"
Cohesion: 0.43
Nodes (3): InvoiceRepository, Invoice, ManagerRegistry
Cohesion: 0.31
Nodes (6): ErrorCodes, ClinicServiceController, JsonResponse, Request, ServiceSection, User
### Community 381 - "Community 381"
Cohesion: 0.13
@@ -2268,6 +2259,10 @@ 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, Version20260628165710
### Community 401 - "Community 401"
Cohesion: 0.12
Nodes (15): `lib/utils.ts`, `SettingsPage.tsx` (ورودی‌ها ریال ذخیره می‌شوند), زمینه, فایل‌های مرتبط, مشکل / هدف, نمونهٔ نمایش (Subscription), نکات مهم, واحد پول = تومان در پنل ادمین (نمایش ÷۱۰ / ورودی ×۱۰) — ذخیره و درگاه ریال می‌ماند (+7 more)
@@ -2277,17 +2272,13 @@ Cohesion: 0.11
Nodes (17): بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (Backend), تست دستی (ddev، در حالت `payment_test_mode=1`), خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (+9 more)
### Community 407 - "Community 407"
Cohesion: 0.27
Nodes (8): MessageBusInterface, MockObject, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver, KavehNegarProvider
Cohesion: 0.16
Nodes (11): MessageBusInterface, MockObject, SmsService, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver (+3 more)
### 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
@@ -2300,21 +2291,25 @@ Nodes (14): زمینه, فایل‌های مرتبط, مشکل / هدف, نما
Cohesion: 0.12
Nodes (16): backend (آماده — فقط annotation/doc ناقص), افزودن فیلد «تاریخ شروع فعالیت» (سال تجربه) در پنل ادمین با تقویم شمسی, زمینه, فایل‌های مرتبط, فرم ساخت پزشک — `DoctorFormPage.tsx` (خط ~282), فرم ویرایش پزشک — `DoctorDetailPage.tsx` (وضعیت فعلی، بدون فیلد تاریخ), مشکل / هدف, نکات مهم (+8 more)
### Community 433 - "Community 433"
Cohesion: 0.35
Nodes (5): PatientController, JsonResponse, PatientSession, Request, User
### Community 435 - "Community 435"
Cohesion: 0.33
Nodes (4): BlogController, JsonResponse, Request, User
### Community 439 - "Community 439"
Cohesion: 0.48
Nodes (3): SmsSettingsRepository, SmsSettings, ManagerRegistry
Cohesion: 0.43
Nodes (3): InvoiceItemRepository, InvoiceItem, ManagerRegistry
### Community 440 - "Community 440"
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.36
Nodes (5): StaffController, ClinicStaff, JsonResponse, Request, User
Cohesion: 0.33
Nodes (6): GET /api/v1/sms/balance — موجودی حساب پیامک, POST /api/v1/sms/queue — افزودن به صف, ۴. سیستم حساب و صف پیامک, ۴.۱ حساب پیامک (SMS Account), ۴.۲ صف پیامک (SMS Queue), ۴.۳ API پیامک
### Community 456 - "Community 456"
Cohesion: 0.15
@@ -2400,25 +2395,21 @@ Nodes (3): CityRepository, City, ManagerRegistry
Cohesion: 0.40
Nodes (5): GET `/api/v1/admin/financial-breakdowns`, GET `/api/v1/admin/financial-summary`, GET `/api/v1/admin/settings/tax-history`, GET `/api/v1/admin/settlement/{uuid}`, موتور مالی نمایندگی
### Community 480 - "Community 480"
Cohesion: 0.36
Nodes (5): StaffController, ClinicStaff, JsonResponse, Request, User
### Community 481 - "Community 481"
Cohesion: 0.40
Nodes (5): Errors, Notes, POST `/api/v1/notification-mobile/request-otp`, Request Body, Response `200`
Cohesion: 0.53
Nodes (3): TaxRateHistoryRepository, ManagerRegistry, TaxRateHistory
### Community 482 - "Community 482"
Cohesion: 0.47
Nodes (3): CommissionService, Payment, Representation
### Community 483 - "Community 483"
Cohesion: 0.40
Nodes (5): Errors, PATCH `/api/v1/doctor/{uuid}`, Path Parameters, Request Body (`application/json`), Response `200`
### Community 485 - "Community 485"
Cohesion: 0.50
Nodes (4): Error Codes, POST `/api/v1/pre-registration`, Request Body, Response `200`
### Community 486 - "Community 486"
Cohesion: 0.50
Nodes (4): Error Codes, POST `/api/v1/user/otp-login`, Request Body, Response `200`
Cohesion: 0.40
Nodes (5): ۲.۸ تنظیمات نوبت (Appointment Settings), ۲.۸.۱ برنامه هفتگی (Weekly Schedule), ۲.۸.۲ تعطیلات (Holidays), ۲.۸.۳ لغو تعطیل (Date Override), ۲.۸.۴ الگوریتم محاسبه اسلات‌های خالی
### Community 487 - "Community 487"
Cohesion: 0.40
@@ -2554,7 +2545,7 @@ Nodes (12): ارسال پیامک OTP فقط از طریق Kavenegar VerifyLooku
### Community 526 - "Community 526"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/user/register`, Request Body, Response `201`
Nodes (4): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین), POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر, Task-09: API تنظیمات نوبت
### Community 527 - "Community 527"
Cohesion: 0.50
@@ -2562,7 +2553,7 @@ Nodes (4): Errors, POST `/api/v1/user/login`, Request Body, Response `200`
### Community 528 - "Community 528"
Cohesion: 0.50
Nodes (4): Errors, POST `/oauth/token`, Request Body, Response `200`
Nodes (4): GET /api/v1/categories/cities — شهرها, GET /api/v1/categories/states — استان‌ها, Task-08: API دسته‌بندی‌ها, سایر Endpoint های دسته‌بندی — الزامی
### Community 529 - "Community 529"
Cohesion: 0.50
@@ -2574,7 +2565,7 @@ Nodes (4): Errors, GET `/oauth/userinfo`, Headers, Response `200`
### Community 531 - "Community 531"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/auth/switch-context`, Request Body, Response `200`
Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظر (Comment), ۲.۱۳.۲ لایک (Like), ۲.۱۳.۳ امتیاز (Rate)
### Community 532 - "Community 532"
Cohesion: 0.50
@@ -2585,7 +2576,7 @@ Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/user/verify-code`, Request Body, Response `200`
### Community 534 - "Community 534"
Cohesion: 0.31
Cohesion: 0.30
Nodes (4): DomainContextResolver, DomainCommissionTest, Payment, Representation
### Community 536 - "Community 536"
@@ -2597,8 +2588,8 @@ Cohesion: 0.15
Nodes (12): اجبار ارسال همه پیامک‌ها از طریق Kavenegar VerifyLookup (حذف مسیر send.json خام), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+4 more)
### Community 539 - "Community 539"
Cohesion: 0.53
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
Cohesion: 0.22
Nodes (3): AppException, SlotTakenException, RuntimeException
### Community 542 - "Community 542"
Cohesion: 0.50
@@ -2669,8 +2660,8 @@ Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/admin/sms/template/{uuid}/reject`, Request Body, Response `200`
### Community 559 - "Community 559"
Cohesion: 0.25
Nodes (4): SmsMessageTemplateRepository, SmsTextResolver, SmsMessageTemplate, ManagerRegistry
Cohesion: 0.16
Nodes (7): SmsMessageController, SmsMessageTemplateRepository, SmsTextResolver, SmsMessageTemplate, JsonResponse, Request, ManagerRegistry
### Community 560 - "Community 560"
Cohesion: 0.50
@@ -2712,6 +2703,10 @@ Nodes (4): ریسک‌های بحرانی, ریسک‌های عملیاتی, ر
Cohesion: 0.12
Nodes (15): دیپلوی ClinicPro (Symfony) روی لیارا با Docker, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی), وظایف, پروژه (+7 more)
### Community 577 - "Community 577"
Cohesion: 0.39
Nodes (3): DoctorService, DoctorServiceRepository, ManagerRegistry
### Community 581 - "Community 581"
Cohesion: 0.50
Nodes (4): API های جدید که باید ساخته شوند, API های موجود (استفاده کن، تغییر نده), تغییر روی API موجود, وضعیت فعلی API (مهم — قبل از پیاده‌سازی بخوان)
@@ -2753,8 +2748,8 @@ Cohesion: 0.40
Nodes (5): Errors, GET `/api/v1/clinic/my-doctor/{doctorUuid}`, Path Parameters, Response `200`, Schedule Fields Notes
### Community 596 - "Community 596"
Cohesion: 0.40
Nodes (5): Errors, PATCH `/api/v1/clinic-pro/doctor-address/{id}`, Path Parameters, Request Body, Response `200`
Cohesion: 0.38
Nodes (3): RepositoryClassMappingTest, KernelTestCase, DbLoggerTest
### Community 597 - "Community 597"
Cohesion: 0.29
@@ -2780,6 +2775,10 @@ Nodes (3): ماژول‌های شناسایی‌شده در PRD, موارد پو
Cohesion: 0.67
Nodes (3): نقاط ضعف, نقاط قوت, ۶. تحلیل API Design
### Community 606 - "Community 606"
Cohesion: 0.15
Nodes (3): LoggerInterface, RanginehProvider, ApiIrService
### Community 607 - "Community 607"
Cohesion: 0.34
Nodes (5): RepresentationController, JsonResponse, Representation, Request, User
@@ -2789,12 +2788,8 @@ Cohesion: 0.67
Nodes (3): بک‌اند, فرانت‌اند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
### Community 618 - "Community 618"
Cohesion: 0.10
Nodes (7): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantInsuranceCleanupTest, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, TenantServiceCoverage
### Community 625 - "Community 625"
Cohesion: 0.22
Nodes (3): AppException, SlotTakenException, RuntimeException
Cohesion: 0.16
Nodes (5): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantServiceCoverageRepository, ManagerRegistry, TenantServiceCoverage
### Community 631 - "Community 631"
Cohesion: 0.50
@@ -2805,8 +2800,8 @@ Cohesion: 0.50
Nodes (4): DELETE `/api/v1/doctor/{uuid}`, Errors, Path Parameters, Response `200`
### Community 633 - "Community 633"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/doctor`, Request Body (`application/json`), Response `201`
Cohesion: 0.48
Nodes (3): SmsSettingsRepository, SmsSettings, ManagerRegistry
### Community 634 - "Community 634"
Cohesion: 0.47
@@ -2816,17 +2811,13 @@ Nodes (3): ImageCropModalProps, createImage(), getCroppedImage()
Cohesion: 0.12
Nodes (16): Runbook — تشخیص «ری‌استارت» سرور: recycle عادی یا خرابی واقعی؟, اقدامات تکمیلی روی سرور (خارج از repo), تأیید روی سرور — اسکریپت آماده, جدول تفسیر خروجی اسکریپت, خلاصه یک‌خطی, علامت مشکل, چرا این اتفاق می‌افتاد (و فیکس اعمال‌شده), چک‌لیست رفع (+8 more)
### Community 636 - "Community 636"
Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/doctor/{uuid}`, Path Parameters, Response `200`
### Community 638 - "Community 638"
Cohesion: 0.50
Nodes (4): Errors, POST `/file/upload/clinic_pro/doctor/field_image`, Request, Response `200`
Cohesion: 0.53
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
### Community 640 - "Community 640"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/clinic-pro/doctor-address`, Request Body, Response `201`
Cohesion: 0.40
Nodes (5): Errors, Notes, POST `/api/v1/notification-mobile/request-otp`, Request Body, Response `200`
### Community 641 - "Community 641"
Cohesion: 0.39
@@ -2836,6 +2827,10 @@ Nodes (3): ClinicStaffRepository, ClinicStaff, ManagerRegistry
Cohesion: 0.14
Nodes (13): اصلاح فیلتر شهر/استان در لیست عمومی پزشکان (`GET /api/v1/doctors`), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+5 more)
### Community 648 - "Community 648"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/settlements`, Query Parameters, Response `200`, Settlement Management
### Community 649 - "Community 649"
Cohesion: 0.14
Nodes (13): زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (flow جدا در SmsWallet — باید حذف شود), وظایف, پروژه, یکسان‌سازی کامل پرداخت به یک Flow واحد + entry ریدایرکت خالص (Backend + Admin) (+5 more)
@@ -2846,7 +2841,7 @@ Nodes (13): زمینه, فایل‌های مرتبط, قیمت هر پیامک
### Community 652 - "Community 652"
Cohesion: 0.50
Nodes (4): Errors, Path Parameters, POST `/api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`, Response `201`
Nodes (4): Error Codes, POST `/api/v1/pre-registration`, Request Body, Response `200`
### Community 654 - "Community 654"
Cohesion: 0.35
@@ -2865,8 +2860,8 @@ Cohesion: 0.20
Nodes (10): Errors (payment refund/reverse/detail), GET `/api/v1/admin/payments`, GET `/api/v1/admin/payments/{uuid}`, Payment Management, POST `/api/v1/admin/payments/{uuid}/refund`, POST `/api/v1/admin/payments/{uuid}/reverse`, Query Parameters, Query Parameters (تکمیل) (+2 more)
### Community 658 - "Community 658"
Cohesion: 0.67
Nodes (3): GET `/api/v1/clinic-pro/doctor-addresses/{doctorId}`, Path Parameters, Response `200`
Cohesion: 0.50
Nodes (4): Error Codes, POST `/api/v1/user/otp-login`, Request Body, Response `200`
### Community 659 - "Community 659"
Cohesion: 0.31
@@ -2888,6 +2883,10 @@ Nodes (5): DELETE `/api/v1/billing/tenant-insurances/{uuid}`, GET `/api/v1/billi
Cohesion: 0.17
Nodes (11): تشخیص «ری‌استارت» سرور روی Coolify + رفع نویز لاگ + بررسی خطای ACME, زمینه, فایل‌های مرتبط, نکات مهم, وضعیت فعلی, وظایف, پروژه, ۱. کاهش نویز لاگ workerها (بدون تغییر رفتار) (+3 more)
### Community 673 - "Community 673"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/user/register`, Request Body, Response `201`
### Community 674 - "Community 674"
Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/admin/insurances`, Query Parameters, Response `200`
@@ -2905,8 +2904,12 @@ Cohesion: 0.43
Nodes (5): BeforeInstallPromptEvent, usePwaInstall(), PwaInstallBanner(), detectIOS(), PwaLoginCard()
### Community 678 - "Community 678"
Cohesion: 0.29
Nodes (6): Appointment Settings API, Available Locations, Errors, `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`, Response `200`, Slot Calculation Logic (Reference)
Cohesion: 0.50
Nodes (4): Errors, POST `/oauth/token`, Request Body, Response `200`
### Community 681 - "Community 681"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/auth/switch-context`, Request Body, Response `200`
### Community 684 - "Community 684"
Cohesion: 0.50
@@ -2984,10 +2987,6 @@ 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, هدرهای اضافی, پاسخ‌ها
@@ -3004,10 +3003,6 @@ Nodes (5): 41. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای
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, هدرهای اضافی, پاسخ‌ها
@@ -3038,7 +3033,7 @@ Nodes (3): GET `/api/v1/representation/{uuid}/dashboard/yearly`, Query Parameter
### Community 732 - "Community 732"
Cohesion: 0.67
Nodes (3): 33. 🔵 `POST` image_clinic, هدرهای اضافی, پاسخ‌ها
Nodes (3): 36. 🟢 `GET` get my rate, هدرهای اضافی, پاسخ‌ها
### Community 733 - "Community 733"
Cohesion: 0.67
@@ -3052,30 +3047,26 @@ Nodes (3): 42. 🔴 `DELETE` delete, هدرهای اضافی, پاسخ‌ها
Cohesion: 0.67
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخ‌ها
### Community 736 - "Community 736"
Cohesion: 0.40
Nodes (3): Props, StatTone, TONE
### Community 738 - "Community 738"
Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/representation/{uuid}`, Path Parameters, Response `200`
## Knowledge Gaps
- **3995 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+3990 more)
- **4007 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+4002 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **151 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 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`?**
- **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 559`, `Community 433`, `Community 435`, `Community 308`, `Community 59`, `Community 318`, `Community 64`, `Community 75`, `Community 77`, `Community 607`, `Community 480`, `Community 230`, `Community 104`, `Community 107`, `Community 109`, `Community 245`, `Community 380`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.035) - 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 169`, `Community 562`, `Community 565`, `Community 573`, `Community 574`, `Community 575`, `Community 717`, `Community 718`, `Community 594`, `Community 82`, `Community 609`, `Community 484`, `Community 618`, `Community 497`, `Community 371`?**
_High betweenness centrality (0.022) - this node is a cross-community bridge._
- **Why does `Version20260705070546` connect `Community 646` to `Community 399`?**
_High betweenness centrality (0.019) - this node is a cross-community bridge._
- **What connects `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps` to the rest of the system?**
_3995 weakly-connected nodes found - possible documentation gaps or missing edges._
_4007 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
+1 -1
View File
File diff suppressed because one or more lines are too long
+2300 -993
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -1460,8 +1460,8 @@
"semantic_hash": ""
},
"src/Representation/Repository/RepresentationRepository.php": {
"mtime": 1783569345.78135,
"ast_hash": "790e3fa6a3be7229e118cbc5e0cd9320",
"mtime": 1783571718.8954258,
"ast_hash": "1fad249b3a01ca2206c7b0eb7dd4a4d2",
"semantic_hash": ""
},
"src/Representation/Service/JalaliDateService.php": {
@@ -2300,8 +2300,8 @@
"semantic_hash": ""
},
"config/services.yaml": {
"mtime": 1783238196.169478,
"ast_hash": "9f71355af38e08d9ebb55a70f3965459",
"mtime": 1783570481.7864468,
"ast_hash": "ed9215189d1b96849ae1e9f6dacdc266",
"semantic_hash": ""
},
"docs/Architecture_Audit.md": {
@@ -3780,18 +3780,28 @@
"semantic_hash": ""
},
"src/Representation/Service/DomainContextResolver.php": {
"mtime": 1783569345.8010752,
"ast_hash": "85a774dafdbdc6e9a9c619943e8cf06c",
"mtime": 1783571733.7229369,
"ast_hash": "48c70212736f0e332ffdd23b038dc788",
"semantic_hash": ""
},
"tests/Representation/DomainCommissionTest.php": {
"mtime": 1783569345.8012395,
"ast_hash": "d5a6e6c27e5cde29618dc797b86589f9",
"mtime": 1783571798.4297788,
"ast_hash": "3a60437f3cc1d9c654d73060a026ddd1",
"semantic_hash": ""
},
".claude/prompt/representation-multi-city-domain-commission.md": {
"mtime": 1783569345.800353,
"ast_hash": "7405ad1b5e6eb371ea5955ffb70195bc",
"semantic_hash": ""
},
"src/Shared/Command/SeedDemoDataCommand.php": {
"mtime": 1783570794.2607677,
"ast_hash": "e4d2144e0632b8c3a9e04ec03a9dd676",
"semantic_hash": ""
},
".claude/prompt/seed-demo-data.md": {
"mtime": 1783570038.522232,
"ast_hash": "0bc13f299e24b463b720695195298c6a",
"semantic_hash": ""
}
}
@@ -29,6 +29,29 @@ class RepresentationRepository extends ServiceEntityRepository
return $this->findOneBy(['domain' => $domain, 'active' => true]);
}
/**
* تطبیق بر اساس اولین label دامنه (TLD-agnostic) هم‌راستا با تطبیق subdomainِ
* شهرها؛ لازم برای dev (مثلاً host=`x-nobat.localhost` باید نماینده‌ی `x-nobat.ir`
* را پیدا کند). فقط وقتی نتیجه یکتاست برمی‌گرداند تا از ابهام (چند TLD روی یک
* prefix) جلوگیری شود.
*/
public function findActiveByDomainPrefix(string $prefix): ?Representation
{
if ($prefix === '') {
return null;
}
$rows = $this->createQueryBuilder('r')
->where('r.active = true')
->andWhere('r.domain LIKE :p')
->setParameter('p', $prefix . '.%')
->setMaxResults(2)
->getQuery()
->getResult();
return count($rows) === 1 ? $rows[0] : null;
}
public function save(Representation $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);
@@ -56,7 +56,10 @@ class DomainContextResolver
return new DomainContext($rep, $city, false);
}
$rep = $this->representationRepo->findActiveByDomain($domain);
// exact ابتدا (production: host = دامنه‌ی واقعی)، سپس fallback اولین-label
// (dev: host = `<prefix>.localhost` باید نماینده‌ی `<prefix>.ir` را بیابد).
$rep = $this->representationRepo->findActiveByDomain($domain)
?? $this->representationRepo->findActiveByDomainPrefix(explode('.', $domain)[0]);
if ($rep !== null) {
return new DomainContext($rep, null, $rep->isGlobal());
}
+800
View File
@@ -0,0 +1,800 @@
<?php
namespace App\Shared\Command;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Settlement\Service\CommissionService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Uid\Uuid;
/**
* دیتای دمو production-like برای تست ماژول نمایندگان و نوبت‌دهی.
*
* Markerهای داده‌ی دمو (مبنای purge با داده‌ی واقعی تداخل ندارند):
* نماینده‌ها: موبایل کاربر 09124000xxx
* بیماران: موبایل 09125xxxxxx
* پزشکان: موبایل کاربر 09126xxxxxx + medical_system_code از 100000
* کلینیک‌ها: موبایل کاربر 09127xxxxxx
* پرداخت‌ها: metadata JSON دارای "demo":true + order_id با پیشوند DEMO-
*
* درج حجیم با DBAL bulk INSERT (chunkهای ۵۰۰تایی)؛ کمیسیونِ سناریوهای match با همان
* فرمول CommissionService::settle مستقیم درج می‌شود و یک زیرمجموعه از مسیر واقعی
* سرویس رد می‌شود تا parity فرمول تضمین بماند.
*
* ddev exec php bin/console app:seed-demo-data --purge
*/
#[AsCommand(name: 'app:seed-demo-data', description: 'Seed production-like demo data (representations, doctors, clinics, users, appointments, commissions)')]
class SeedDemoDataCommand extends Command
{
private const CHUNK = 500;
private const GLOBAL_REP_DOMAINS = ['x-nobat.ir', 'global-doctor.ir', 'iran-doc.ir', 'salamat-nobat.ir', 'doc24.ir'];
private const FIRST_NAMES = ['علی', 'محمد', 'حسین', 'رضا', 'مهدی', 'امیر', 'سعید', 'حسن', 'مجید', 'احمد', 'مریم', 'زهرا', 'فاطمه', 'سارا', 'نرگس', 'لیلا', 'مینا', 'شیما', 'الهام', 'نسرین', 'کامران', 'بهرام', 'فرهاد', 'پیمان', 'آرش'];
private const LAST_NAMES = ['احمدی', 'محمدی', 'رضایی', 'کریمی', 'حسینی', 'موسوی', 'جعفری', 'صادقی', 'رحیمی', 'نجفی', 'قاسمی', 'هاشمی', 'اکبری', 'امینی', 'شریفی', 'توکلی', 'زارعی', 'مرادی', 'عباسی', 'فلاحی', 'نوری', 'سلطانی', 'کاظمی', 'یوسفی', 'باقری'];
private const SPECIALTY_NAMES = ['قلب', 'داخلی', 'ارتوپدی', 'پوست و مو', 'چشم', 'زنان', 'کودکان', 'دندانپزشکی', 'مغز و اعصاب', 'روانپزشکی'];
/** @var array<int,int> user_id نماینده → موجودی جاری کیف‌پول (برای balance_after) */
private array $walletBalance = [];
public function __construct(
private readonly Connection $db,
private readonly EntityManagerInterface $em,
private readonly SiteConfigRepository $configRepo,
private readonly CommissionService $commissionService,
private readonly string $environment,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('doctors', null, InputOption::VALUE_REQUIRED, 'تعداد پزشک', '500')
->addOption('clinics', null, InputOption::VALUE_REQUIRED, 'تعداد کلینیک', '200')
->addOption('users', null, InputOption::VALUE_REQUIRED, 'تعداد بیمار', '10000')
->addOption('appointments', null, InputOption::VALUE_REQUIRED, 'تعداد نوبت', '20000')
->addOption('purge', null, InputOption::VALUE_NONE, 'حذف داده‌ی دموی قبلی پیش از seed')
->addOption('force', null, InputOption::VALUE_NONE, 'اجازه‌ی اجرا روی prod');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($this->environment === 'prod' && !$input->getOption('force')) {
$io->error('این seeder برای محیط تست است؛ روی prod فقط با --force اجرا می‌شود.');
return Command::FAILURE;
}
$nDoctors = max(1, (int) $input->getOption('doctors'));
$nClinics = max(1, (int) $input->getOption('clinics'));
$nUsers = max(1, (int) $input->getOption('users'));
$nAppointments = max(1, (int) $input->getOption('appointments'));
mt_srand(42); // اجراهای تکرارپذیر
if ($input->getOption('purge')) {
$this->purge($io);
}
$cities = $this->db->fetchAllAssociative('SELECT id, name, domain FROM cities WHERE domain IS NOT NULL ORDER BY id');
if (count($cities) < 5) {
$io->error('جدول cities خالی است — اول app:seed-categories را اجرا کنید.');
return Command::FAILURE;
}
$specialtyIds = $this->resolveSpecialtyIds($io);
// کمیسیون‌ها باید فعال باشند تا مسیر واقعی سرویس هم ثبت کند.
$this->configRepo->set('appointment_commission_enabled', '1');
$this->configRepo->set('upgrade_commission_enabled', '1');
if ($this->configRepo->get('upgrade_commission_percent') === null) {
$this->configRepo->set('upgrade_commission_percent', '20');
}
$now = time();
$io->section('۱) نمایندگان');
$reps = $this->seedRepresentations($cities, $now);
$io->text(sprintf('%d شهری + %d سراسری', 20, 5));
$io->section('۲) بیماران');
$patientIds = $this->seedPatients($nUsers, $now, $io);
$io->section('۳) پزشکان + آدرس + برنامه هفتگی');
$doctors = $this->seedDoctors($nDoctors, $reps, $now, $specialtyIds, $io);
$io->section('۴) کلینیک‌ها');
$this->seedClinics($nClinics, $reps, $doctors, $now, $io);
$io->section('۵) نوبت‌ها + پرداخت‌ها + کمیسیون');
$scenarioCounts = $this->seedAppointments($nAppointments, $reps, $doctors, $patientIds, $now, $io);
$io->section('۶) اشتراک‌ها');
$subCounts = $this->seedSubscriptions($reps, $doctors, $now, $io);
$io->section('۷) parity با CommissionService واقعی');
$parityOk = $this->verifyParity($reps, $doctors, $patientIds, $now, $io);
$this->report($io, $scenarioCounts, $subCounts, $parityOk);
return $parityOk ? Command::SUCCESS : Command::FAILURE;
}
// ── purge ────────────────────────────────────────────────────────────────
private function purge(SymfonyStyle $io): void
{
$io->section('purge داده‌ی دموی قبلی');
// پرداخت‌های parity از مسیر واقعی سرویس order_id استاندارد ORD- می‌گیرند → marker اصلی metadata است.
$demoPayments = 'SELECT id FROM payments WHERE order_id LIKE "DEMO-%" OR metadata LIKE \'%"demo":true%\'';
$demoDoctors = 'SELECT id FROM doctors WHERE medical_system_code >= 100000 AND medical_system_code < 101000';
$demoUsers = "SELECT id FROM users WHERE mobile_number LIKE '09124000%' OR mobile_number LIKE '09125%' OR mobile_number LIKE '09126%' OR mobile_number LIKE '09127%'";
$steps = [
"DELETE FROM financial_breakdowns WHERE payment_id IN ($demoPayments)",
"DELETE FROM wallet_transactions WHERE payment_id IN ($demoPayments)",
"DELETE FROM clinic_subscriptions WHERE payment_id IN ($demoPayments)",
"DELETE FROM appointments WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM payments WHERE order_id LIKE 'DEMO-%' OR metadata LIKE '%\"demo\":true%'",
"DELETE FROM weekly_schedules WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM clinic_doctors WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM doctor_specialties WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM doctor_addresses WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM clinics WHERE user_id IN (SELECT id FROM users WHERE mobile_number LIKE '09127%')",
"DELETE FROM doctors WHERE medical_system_code >= 100000 AND medical_system_code < 101000",
"DELETE FROM representations WHERE user_id IN (SELECT id FROM users WHERE mobile_number LIKE '09124000%')",
"DELETE FROM users WHERE id IN ($demoUsers)",
];
foreach ($steps as $sql) {
// MariaDB روی DELETE با subquery از همان جدول خطا می‌دهد → از جدول موقتِ derived استفاده کن.
$sql = preg_replace('/IN \((SELECT id FROM users[^)]*)\)$/', 'IN (SELECT id FROM (\1) AS t)', $sql) ?? $sql;
$this->db->executeStatement($sql);
}
$io->text('purge انجام شد.');
}
// ── سازنده‌های پایه ──────────────────────────────────────────────────────
/** درج bulk با chunk؛ rows = لیست ردیف‌های associative با کلیدهای یکسان. */
private function bulkInsert(string $table, array $rows): void
{
if ($rows === []) return;
$cols = array_keys($rows[0]);
$colSql = implode(', ', array_map(fn($c) => "`$c`", $cols));
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
$placeholders = [];
$params = [];
foreach ($chunk as $row) {
$placeholders[] = '(' . implode(', ', array_fill(0, count($cols), '?')) . ')';
foreach ($cols as $c) $params[] = $row[$c];
}
$this->db->executeStatement(
"INSERT INTO `$table` ($colSql) VALUES " . implode(', ', $placeholders),
$params,
);
}
}
private function insertUser(string $mobile, string $name, array $roles, int $now): int
{
$this->db->insert('users', [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => $mobile,
'real_name' => $name,
'roles' => json_encode($roles),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->db->lastInsertId();
}
private function persianName(int $i): string
{
return self::FIRST_NAMES[$i % 25] . ' ' . self::LAST_NAMES[intdiv($i, 25) % 25];
}
private function resolveSpecialtyIds(SymfonyStyle $io): array
{
$rows = $this->db->fetchAllAssociative('SELECT id, name FROM specialties');
$byName = array_column($rows, 'id', 'name');
$ids = [];
foreach (self::SPECIALTY_NAMES as $name) {
foreach ($byName as $n => $id) {
if (str_contains((string) $n, $name)) { $ids[] = (int) $id; continue 2; }
}
}
if ($ids === []) {
$ids = array_map('intval', array_slice(array_column($rows, 'id'), 0, 10));
$io->warning('تخصص‌های خواسته‌شده در جدول نبود — از ۱۰ تخصص اول استفاده شد.');
}
return $ids;
}
// ── نمایندگان ────────────────────────────────────────────────────────────
/** @return array{city: array<int,array>, global: array<int,array>} */
private function seedRepresentations(array $cities, int $now): array
{
$cityReps = [];
$cityPool = $cities;
for ($i = 0; $i < 20; $i++) {
$name = 'نماینده ' . $this->persianName($i);
$userId = $this->insertUser(sprintf('09124000%03d', $i + 1), $name, ['ROLE_USER', 'ROLE_REPRESENTATION'], $now);
// ۱ تا ۳ شهر؛ شهر اصلی = دامنه‌ی نماینده (مدل واقعی نماینده‌ی شهری).
$count = 1 + ($i % 3);
$chosen = [];
for ($k = 0; $k < $count && $cityPool !== []; $k++) {
$idx = ($i * 3 + $k) % count($cityPool);
$chosen[] = $cityPool[$idx];
array_splice($cityPool, $idx, 1);
}
// استخر شهرِ اختصاص‌نیافته تمام شد → عضویت از لیست کامل، دامنه مصنوعی یکتا
// (دامنه‌ی شهری فقط وقتی که آن شهر هنوز نماینده‌ی دامنه‌دار ندارد).
$domain = $chosen !== [] ? $chosen[0]['domain'] : sprintf('demo-rep%02d.ir', $i + 1);
if ($chosen === []) $chosen[] = $cities[$i % count($cities)];
$this->db->insert('representations', [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'full_name' => $name,
'mobile_number' => sprintf('09124000%03d', $i + 1),
'city_id' => $chosen[0]['id'],
'domain' => $domain,
'is_global' => 0,
'commission_percent' => (string) (8 + ($i % 8)),
'active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$repId = (int) $this->db->lastInsertId();
foreach ($chosen as $c) {
$this->db->insert('representation_cities', ['representation_id' => $repId, 'city_id' => $c['id']]);
}
$this->walletBalance[$userId] = 0;
$cityReps[] = ['id' => $repId, 'user_id' => $userId, 'domain' => $domain, 'percent' => 8 + ($i % 8), 'cities' => array_column($chosen, 'id')];
}
$globalReps = [];
foreach (self::GLOBAL_REP_DOMAINS as $g => $domain) {
$name = 'نماینده سراسری ' . $this->persianName(20 + $g);
$userId = $this->insertUser(sprintf('09124000%03d', 100 + $g), $name, ['ROLE_USER', 'ROLE_REPRESENTATION'], $now);
$this->db->insert('representations', [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'full_name' => $name,
'mobile_number' => sprintf('09124000%03d', 100 + $g),
'city_id' => null,
'domain' => $domain,
'is_global' => 1,
'commission_percent' => (string) (10 + $g),
'active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$this->walletBalance[$userId] = 0;
$globalReps[] = ['id' => (int) $this->db->lastInsertId(), 'user_id' => $userId, 'domain' => $domain, 'percent' => 10 + $g, 'cities' => [$cities[$g]['id']]];
}
return ['city' => $cityReps, 'global' => $globalReps];
}
// ── بیماران ──────────────────────────────────────────────────────────────
/** @return int[] */
private function seedPatients(int $count, int $now, SymfonyStyle $io): array
{
$rows = [];
for ($i = 0; $i < $count; $i++) {
$rows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => sprintf('09125%06d', $i),
'real_name' => $this->persianName($i),
'roles' => json_encode(['ROLE_USER']),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now - mt_rand(0, 180 * 86400),
'updated_at' => $now,
];
}
$this->bulkInsert('users', $rows);
$ids = $this->db->fetchFirstColumn("SELECT id FROM users WHERE mobile_number LIKE '09125%' ORDER BY id");
$io->text(count($ids) . ' بیمار');
return array_map('intval', $ids);
}
// ── پزشکان ───────────────────────────────────────────────────────────────
/** @return array<int,array{id:int,rep:?array,address_id:int,duration:int,user_id:int}> */
private function seedDoctors(int $count, array $reps, int $now, array $specialtyIds, SymfonyStyle $io): array
{
$allReps = array_merge($reps['city'], $reps['global']);
// توزیع مالکیت: ~۱۰٪ بدون نماینده؛ سراسری‌ها هر کدام سهم ثابت؛ بقیه بین شهری‌ها.
$userRows = [];
for ($i = 0; $i < $count; $i++) {
$userRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => sprintf('09126%06d', $i),
'real_name' => 'دکتر ' . $this->persianName($i),
'roles' => json_encode(['ROLE_USER', 'ROLE_DOCTOR']),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('users', $userRows);
$doctorUserIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM users WHERE mobile_number LIKE '09126%' ORDER BY id"));
$doctorRows = [];
$ownership = [];
foreach ($doctorUserIds as $i => $userId) {
if ($i % 10 === 9) {
$rep = null; // بدون نماینده
} elseif ($i % 10 >= 7) {
$rep = $reps['global'][intdiv($i, 10) % 5]; // ~۲۰٪ سراسری، چرخشی بین هر ۵
} else {
$rep = $reps['city'][$i % 20]; // بقیه شهری
}
$ownership[$i] = $rep;
$doctorRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'name' => 'دکتر ' . $this->persianName($i),
'gender' => $i % 3 === 0 ? 'woman' : 'man',
'medical_system_code' => (string) (100000 + $i),
'mobile_number' => sprintf('09126%06d', $i),
'degree' => ['specialist', 'general', 'subspecialist'][$i % 3],
'active_doctor_appointment' => $i % 7 === 6 ? 0 : 1,
'representation_id' => $rep['id'] ?? null,
'doctor_rate' => mt_rand(30, 50) / 10,
'doctor_rate_percentage' => mt_rand(50, 100),
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('doctors', $doctorRows);
$doctorIds = array_map('intval', $this->db->fetchFirstColumn('SELECT id FROM doctors WHERE medical_system_code >= 100000 AND medical_system_code < 101000 ORDER BY id'));
// تخصص + آدرس + برنامه هفتگی
$specRows = $addrRows = [];
foreach ($doctorIds as $i => $docId) {
$specRows[] = ['doctor_id' => $docId, 'specialty_id' => $specialtyIds[$i % count($specialtyIds)]];
$rep = $ownership[$i];
$cityId = $rep !== null ? $rep['cities'][$i % count($rep['cities'])] : 108; // بدون نماینده → تهران
$addrRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'doctor_id' => $docId,
'name' => 'مطب',
'address' => 'خیابان اصلی، پلاک ' . ($i + 1),
'telephone' => sprintf('0219998%04d', $i),
'city_id' => $cityId,
'type' => 'personal',
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('doctor_specialties', $specRows);
$this->bulkInsert('doctor_addresses', $addrRows);
$addrIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM doctor_addresses WHERE telephone LIKE '0219998%' ORDER BY doctor_id"));
$scheduleRows = [];
$doctors = [];
foreach ($doctorIds as $i => $docId) {
$duration = [15, 20, 30][$i % 3];
$days = 3 + ($i % 4); // ۳ تا ۶ روز کاری
$setting = [];
for ($d = 0; $d < 7; $d++) {
if ($d >= $days) continue;
$sessions = [$this->session('08:00', '14:00', $duration, $addrIds[$i])];
if ($i % 2 === 0) $sessions[] = $this->session('16:00', '21:00', $duration, $addrIds[$i]);
$setting[(string) $d] = ['sessions' => $sessions];
}
$scheduleRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'doctor_id' => $docId,
'setting' => json_encode($setting, JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now,
];
$doctors[] = ['id' => $docId, 'rep' => $ownership[$i], 'address_id' => $addrIds[$i], 'duration' => $duration, 'user_id' => $doctorUserIds[$i]];
}
$this->bulkInsert('weekly_schedules', $scheduleRows);
$io->text(count($doctors) . ' پزشک + آدرس + برنامه هفتگی');
return $doctors;
}
private function session(string $start, string $end, int $duration, int $locationId): array
{
return [
'active' => true, 'location_id' => $locationId,
'start_time' => $start, 'end_time' => $end,
'duration_per_patient' => $duration,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null,
];
}
// ── کلینیک‌ها ────────────────────────────────────────────────────────────
private function seedClinics(int $count, array $reps, array $doctors, int $now, SymfonyStyle $io): void
{
$allReps = array_merge($reps['city'], $reps['global']);
$userRows = [];
for ($i = 0; $i < $count; $i++) {
$userRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => sprintf('09127%06d', $i),
'real_name' => 'مدیر کلینیک ' . ($i + 1),
'roles' => json_encode(['ROLE_USER', 'ROLE_CLINIC']),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('users', $userRows);
$clinicUserIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM users WHERE mobile_number LIKE '09127%' ORDER BY id"));
$clinicRows = [];
foreach ($clinicUserIds as $i => $userId) {
$rep = $allReps[$i % count($allReps)];
$clinicRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'name' => 'کلینیک ' . self::LAST_NAMES[$i % 25] . ' ' . ($i + 1),
'address' => 'بلوار مرکزی، ساختمان ' . ($i + 1),
'telephone' => sprintf('0219997%04d', $i),
'city_id' => $rep['cities'][0],
'representation_id' => $rep['id'],
'is_active' => 1,
'is_24_7' => 0,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('clinics', $clinicRows);
$clinicIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM clinics WHERE telephone LIKE '0219997%' ORDER BY id"));
// اتصال ۱–۵ پزشکِ همان نماینده به هر کلینیک.
$byRep = [];
foreach ($doctors as $d) {
if ($d['rep'] !== null) $byRep[$d['rep']['id']][] = $d['id'];
}
$linkRows = [];
foreach ($clinicIds as $i => $clinicId) {
$rep = $allReps[$i % count($allReps)];
$pool = $byRep[$rep['id']] ?? [];
foreach (array_slice($pool, 0, 1 + ($i % 5)) as $docId) {
$linkRows[] = ['clinic_id' => $clinicId, 'doctor_id' => $docId];
}
}
// حذف تکراری‌ها (PK مرکب)
$seen = [];
$linkRows = array_values(array_filter($linkRows, function ($r) use (&$seen) {
$k = $r['clinic_id'] . '-' . $r['doctor_id'];
if (isset($seen[$k])) return false;
return $seen[$k] = true;
}));
$this->bulkInsert('clinic_doctors', $linkRows);
$io->text(count($clinicIds) . ' کلینیک + ' . count($linkRows) . ' اتصال پزشک');
}
// ── نوبت‌ها + پرداخت + کمیسیون ───────────────────────────────────────────
/** @return array<string,int> */
private function seedAppointments(int $count, array $reps, array $doctors, array $patientIds, int $now, SymfonyStyle $io): array
{
$allReps = array_merge($reps['city'], $reps['global']);
$fee = max(100000, (int) ($this->configRepo->get('appointment_fee_rials') ?: 1500000));
$smsFee = (int) ($this->configRepo->get('sms_panel_fee_rials') ?: 0);
$taxOn = $this->configRepo->get('tax_enabled') === '1';
$taxPct = $taxOn ? (float) $this->configRepo->get('tax_percent') : 0.0;
$today = strtotime('today');
$counts = ['match' => 0, 'mismatch' => 0, 'cancelled' => 0, 'pending' => 0];
$apptRows = $payRows = $bdRows = $wtRows = [];
$io->progressStart($count);
for ($i = 0; $i < $count; $i++) {
$doc = $doctors[$i % count($doctors)];
$dur = $doc['duration'] * 60;
$dayOffset = mt_rand(-60, 14);
$base = $today + $dayOffset * 86400 + 8 * 3600;
$slotStart = $base + mt_rand(0, intdiv(6 * 3600, $dur) - 1) * $dur;
$slotEnd = $slotStart + $dur;
$patient = $patientIds[$i % count($patientIds)];
$apptUuid = Uuid::v4()->toRfc4122();
$r = $i % 20;
if ($r < 12 && $doc['rep'] !== null) $scenario = 'match'; // 60%
elseif ($r < 15 && $doc['rep'] !== null) $scenario = 'mismatch'; // 15%
elseif ($r < 18) $scenario = 'cancelled'; // 15%
else $scenario = 'pending'; // 10%
if ($doc['rep'] === null && in_array($scenario, ['match', 'mismatch'], true)) $scenario = 'pending';
$counts[$scenario]++;
$status = match ($scenario) {
'match', 'mismatch' => $slotStart < $now ? 'completed' : 'confirmed',
'cancelled' => 'cancelled_by_user',
'pending' => 'pending',
};
$bookingRepId = null;
$domain = null;
if ($scenario === 'match') {
$domain = $doc['rep']['domain'];
$bookingRepId = $doc['rep']['id'];
} elseif ($scenario === 'mismatch') {
$other = $allReps[($i + 7) % count($allReps)];
if ($other['id'] === $doc['rep']['id']) $other = $allReps[($i + 8) % count($allReps)];
$domain = $other['domain'];
$bookingRepId = $other['id'];
}
$apptRows[] = [
'uuid' => $apptUuid, 'version' => 1,
'slot_start' => $slotStart, 'slot_end' => $slotEnd, 'status' => $status,
'doctor_id' => $doc['id'], 'user_id' => $patient,
'patient_name' => $this->persianName($i), 'patient_mobile' => sprintf('09125%06d', $i % count($patientIds)),
'patient_national_code' => sprintf('%010d', 1000000000 + $i), 'patient_gender' => $i % 2 ? 'man' : 'woman',
'address_id' => $doc['address_id'], 'booking_representation_id' => $bookingRepId,
'created_at' => min($slotStart, $now) - 3600, 'updated_at' => $now,
];
if ($scenario === 'match' || $scenario === 'mismatch') {
$payRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'order_id' => 'DEMO-A' . $i,
'amount_rials' => $fee, 'status' => 'success', 'gateway' => 'mock', 'type' => 'appointment',
'reference_id' => 'DEMOREF-A' . $i,
'frontend_address' => 'https://' . $domain . '/payment/result',
'user_id' => $patient,
'metadata' => json_encode(['demo' => true, 'scenario' => $scenario, 'appt_uuid' => $apptUuid]),
'created_at' => min($slotStart, $now) - 3000, 'updated_at' => $now,
];
if ($scenario === 'match') {
$rep = $doc['rep'];
[$bd, $wt] = $this->breakdownRows('appointment', $fee, $smsFee, $taxOn, $taxPct, (float) $rep['percent'], $rep, $doc['id'], null, $patient, $now, 'DEMO-A' . $i);
$bdRows[] = $bd;
if ($wt !== null) $wtRows[] = $wt;
}
}
$io->progressAdvance();
}
$io->progressFinish();
$this->bulkInsert('appointments', $apptRows);
$this->bulkInsert('payments', $payRows);
// payment_id واقعی را به breakdownها وصل کن (بر اساس order_id).
$payIdByOrder = [];
foreach ($this->db->fetchAllAssociative("SELECT id, order_id FROM payments WHERE order_id LIKE 'DEMO-A%'") as $p) {
$payIdByOrder[$p['order_id']] = (int) $p['id'];
}
foreach ($bdRows as &$bd) { $bd['payment_id'] = $payIdByOrder[$bd['payment_id']]; }
unset($bd);
foreach ($wtRows as &$wt) { $wt['payment_id'] = $payIdByOrder[$wt['payment_id']]; }
unset($wt);
$this->bulkInsert('financial_breakdowns', $bdRows);
$this->bulkInsert('wallet_transactions', $wtRows);
$io->text(sprintf('نوبت: %d | پرداخت: %d | کمیسیون match: %d', count($apptRows), count($payRows), count($bdRows)));
return $counts;
}
/**
* همان فرمول CommissionService::settle parity در verifyParity() چک می‌شود.
* payment_id موقتاً order_id است و بعد از درج پرداخت‌ها resolve می‌شود.
* @return array{0: array, 1: ?array}
*/
private function breakdownRows(string $source, int $gross, int $smsFee, bool $taxOn, float $taxPct, float $percent, array $rep, ?int $doctorId, ?int $clinicId, int $userId, int $now, string $orderRef): array
{
$afterSms = max(0, $gross - $smsFee);
$taxRials = ($taxOn && $taxPct > 0) ? (int) round($afterSms * $taxPct / (100 + $taxPct)) : 0;
$netAfterTax = $afterSms - $taxRials;
$repShare = (int) round($netAfterTax * $percent / 100);
$bd = [
'uuid' => Uuid::v4()->toRfc4122(), 'source' => $source,
'gross_rials' => $gross, 'sms_fee_rials' => $smsFee,
'tax_percent' => number_format($taxPct, 2, '.', ''), 'tax_rials' => $taxRials,
'net_after_tax_rials' => $netAfterTax,
'commission_percent' => number_format($percent, 2, '.', ''),
'representation_share_rials' => $repShare,
'system_share_rials' => $gross - $smsFee - $taxRials - $repShare,
'representation_id' => $rep['id'], 'doctor_id' => $doctorId, 'clinic_id' => $clinicId,
'user_id' => $userId, 'payment_id' => $orderRef, 'created_at' => $now,
];
$wt = null;
if ($repShare > 0) {
$this->walletBalance[$rep['user_id']] += $repShare;
$wt = [
'uuid' => Uuid::v4()->toRfc4122(),
'amount_rials' => $repShare, 'type' => 'credit',
'description' => 'پورسانت ' . $source . ' ' . $orderRef,
'balance_after' => $this->walletBalance[$rep['user_id']],
'user_id' => $rep['user_id'], 'payment_id' => $orderRef, 'created_at' => $now,
];
}
return [$bd, $wt];
}
// ── اشتراک‌ها ────────────────────────────────────────────────────────────
/** @return array<string,int> */
private function seedSubscriptions(array $reps, array $doctors, int $now, SymfonyStyle $io): array
{
$period = $this->db->fetchAssociative('SELECT id, plan_id FROM subscription_periods ORDER BY id LIMIT 1');
if ($period === false) {
$io->warning('subscription_periods خالی است — اشتراک seed نشد.');
return ['sub_match' => 0, 'sub_mismatch' => 0];
}
$allReps = array_merge($reps['city'], $reps['global']);
$owned = array_values(array_filter($doctors, fn($d) => $d['rep'] !== null));
$smsFee = (int) ($this->configRepo->get('sms_panel_fee_rials') ?: 0);
$taxOn = $this->configRepo->get('tax_enabled') === '1';
$taxPct = $taxOn ? (float) $this->configRepo->get('tax_percent') : 0.0;
$upPct = (float) $this->configRepo->get('upgrade_commission_percent');
$amount = 5000000;
$payRows = $subRows = $bdRows = $wtRows = [];
$counts = ['sub_match' => 0, 'sub_mismatch' => 0];
for ($i = 0; $i < 100 && $i < count($owned); $i++) {
$doc = $owned[$i * 3 % count($owned)];
$match = $i % 2 === 0;
$rep = $doc['rep'];
$domain = $match ? $rep['domain'] : $allReps[($i + 9) % count($allReps)]['domain'];
if (!$match && $domain === $rep['domain']) $domain = $allReps[($i + 10) % count($allReps)]['domain'];
$counts[$match ? 'sub_match' : 'sub_mismatch']++;
$payRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'order_id' => 'DEMO-S' . $i,
'amount_rials' => $amount, 'status' => 'success', 'gateway' => 'mock', 'type' => 'subscription',
'reference_id' => 'DEMOREF-S' . $i,
'frontend_address' => 'https://' . $domain . '/panel',
'user_id' => $doc['user_id'],
'metadata' => json_encode(['demo' => true, 'scenario' => $match ? 'sub_match' : 'sub_mismatch']),
'created_at' => $now - mt_rand(0, 30 * 86400), 'updated_at' => $now,
];
$subRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'entity_type' => 'doctor', 'entity_id' => $doc['id'], 'is_trial' => 0,
'starts_at' => $now - 86400, 'expires_at' => $now + 30 * 86400,
'plan_id' => (int) $period['plan_id'], 'period_id' => (int) $period['id'],
'payment_id' => 'DEMO-S' . $i, 'created_at' => $now,
];
if ($match) {
[$bd, $wt] = $this->breakdownRows('subscription', $amount, $smsFee, $taxOn, $taxPct, $upPct, $rep, $doc['id'], null, $doc['user_id'], $now, 'DEMO-S' . $i);
$bdRows[] = $bd;
if ($wt !== null) $wtRows[] = $wt;
}
}
$this->bulkInsert('payments', $payRows);
$payIdByOrder = [];
foreach ($this->db->fetchAllAssociative("SELECT id, order_id FROM payments WHERE order_id LIKE 'DEMO-S%'") as $p) {
$payIdByOrder[$p['order_id']] = (int) $p['id'];
}
foreach ($subRows as &$s) { $s['payment_id'] = $payIdByOrder[$s['payment_id']]; }
unset($s);
foreach ($bdRows as &$bd) { $bd['payment_id'] = $payIdByOrder[$bd['payment_id']]; }
unset($bd);
foreach ($wtRows as &$wt) { $wt['payment_id'] = $payIdByOrder[$wt['payment_id']]; }
unset($wt);
$this->bulkInsert('clinic_subscriptions', $subRows);
$this->bulkInsert('financial_breakdowns', $bdRows);
$this->bulkInsert('wallet_transactions', $wtRows);
$io->text(sprintf('اشتراک: %d (match: %d، mismatch: %d)', count($subRows), $counts['sub_match'], $counts['sub_mismatch']));
return $counts;
}
// ── parity با سرویس واقعی ────────────────────────────────────────────────
private function verifyParity(array $reps, array $doctors, array $patientIds, int $now, SymfonyStyle $io): bool
{
$fee = max(100000, (int) ($this->configRepo->get('appointment_fee_rials') ?: 1500000));
$ok = true;
$sample = array_values(array_filter($doctors, fn($d) => $d['rep'] !== null));
for ($i = 0; $i < 100 && $i < count($sample); $i++) {
$doc = $sample[$i];
$rep = $doc['rep'];
$user = $this->em->getReference(\App\Auth\Entity\User::class, $patientIds[$i]);
$payment = new Payment($user, $fee, 'mock', Payment::TYPE_APPOINTMENT, 'https://' . $rep['domain'] . '/payment/result');
$payment->setMetadata(['demo' => true, 'scenario' => 'match-service']);
$this->em->persist($payment);
$this->em->flush();
$this->commissionService->processAppointment($payment, $rep['id'], $rep['id'], $doc['id']);
$share = $this->db->fetchOne('SELECT representation_share_rials FROM financial_breakdowns WHERE payment_id = ?', [$payment->getId()]);
$expected = $this->expectedShare($fee, (float) $rep['percent']);
if ((int) $share !== $expected) {
$io->error(sprintf('parity شکست: سرویس=%s، فرمول bulk=%d (rep %d)', var_export($share, true), $expected, $rep['id']));
$ok = false;
break;
}
$this->em->clear();
}
if ($ok) $io->text('parity تایید شد — فرمول bulk == CommissionService (۱۰۰ نمونه).');
return $ok;
}
private function expectedShare(int $gross, float $percent): int
{
$smsFee = (int) ($this->configRepo->get('sms_panel_fee_rials') ?: 0);
$afterSms = max(0, $gross - $smsFee);
$taxOn = $this->configRepo->get('tax_enabled') === '1';
$taxPct = $taxOn ? (float) $this->configRepo->get('tax_percent') : 0.0;
$taxRials = ($taxOn && $taxPct > 0) ? (int) round($afterSms * $taxPct / (100 + $taxPct)) : 0;
return (int) round(($afterSms - $taxRials) * $percent / 100);
}
// ── گزارش ────────────────────────────────────────────────────────────────
private function report(SymfonyStyle $io, array $scenarioCounts, array $subCounts, bool $parityOk): void
{
$io->section('گزارش نهایی');
$tables = ['representations', 'representation_cities', 'doctors', 'clinics', 'clinic_doctors', 'weekly_schedules', 'users', 'appointments', 'payments', 'clinic_subscriptions', 'financial_breakdowns', 'wallet_transactions'];
$rows = [];
foreach ($tables as $t) {
$rows[] = [$t, number_format((int) $this->db->fetchOne("SELECT COUNT(*) FROM `$t`"))];
}
$io->table(['جدول', 'تعداد کل'], $rows);
$io->table(['سناریو', 'تعداد'], array_map(fn($k, $v) => [$k, $v], array_keys($scenarioCounts + $subCounts), array_values($scenarioCounts + $subCounts)));
$mismatchLeak = (int) $this->db->fetchOne(
"SELECT COUNT(*) FROM financial_breakdowns b JOIN payments p ON p.id = b.payment_id
WHERE p.metadata LIKE '%\"scenario\":\"mismatch\"%' OR p.metadata LIKE '%\"scenario\":\"sub_mismatch\"%'"
);
$walletParity = $this->db->fetchOne(
'SELECT (SELECT COALESCE(SUM(representation_share_rials),0) FROM financial_breakdowns)
= (SELECT COALESCE(SUM(amount_rials),0) FROM wallet_transactions WHERE type = "credit")'
);
$io->listing([
'کمیسیون نشت‌کرده به سناریوهای mismatch: ' . $mismatchLeak . ' (باید 0 باشد)',
'برابری مجموع سهم نماینده و کیف‌پول: ' . ($walletParity ? 'OK' : 'FAIL'),
'parity فرمول با CommissionService: ' . ($parityOk ? 'OK' : 'FAIL'),
]);
$io->section('سناریوهای تست دستی');
$io->listing([
'GET /api/v1/doctors?domain=x-nobat.ir → فقط پزشکان نماینده سراسری اول',
'GET /api/v1/doctors?domain=<دامنه شهر نماینده اول> → رفتار شهری عادی',
'GET /api/v1/site-context?domain=x-nobat.ir → type=representation و is_global=true',
'ورود با موبایل 09124000001 (نماینده ۱) → داشبورد نماینده: درآمد > 0',
'پنل ادمین → نمایندگان → badge «سراسری» روی ۵ نماینده و ستون شهرهای چندتایی',
]);
}
}
@@ -78,6 +78,18 @@ class DomainCommissionTest extends ApiTestCase
$this->assertNull($ctx->city);
}
public function testResolverMatchesByFirstLabelForLocalDev(): void
{
$prefix = 'repdev' . substr(uniqid(), -6);
$rep = $this->makeRep($prefix . '.ir', global: true);
// host لوکال با TLD متفاوت (`.localhost`) باید همان نماینده را بیابد.
$ctx = $this->resolver()->resolve($prefix . '.localhost');
$this->assertSame($rep->getId(), $ctx->representationId());
$this->assertTrue($ctx->isGlobalRepresentation);
}
// ── CommissionService: گارد دوشرطی ───────────────────────────────────────
public function testAppointmentCommissionOnlyWhenDomainOwnerMatchesDoctorOwner(): void