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()`.