diff --git a/TEST_USERS.md b/TEST_USERS.md index d412ee5f..52d5470e 100644 --- a/TEST_USERS.md +++ b/TEST_USERS.md @@ -1,110 +1,111 @@ # کاربران تستی -**پنل ادمین:** https://clinic-pro.ddev.site/admin +**پنل ادمین:** https://clinic-pro.ddev.site/admin — **سایت عمومی:** `yasuj-nobat.localhost:3000` -> رمز عبور همهٔ پرسوناها: `QaTest@1234` +> رمز عبور همهٔ حساب‌های کارکنان: `QaTest@1234` · کد OTP در dev همیشه `12345` -این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را -توصیف می‌کند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل -تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است. - ---- - -## پرسوناها - -واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR` -دارند ولی دادهٔ متفاوتی می‌بینند. - -| پرسونا | موبایل | نقش‌ها | تمایز | -|---|---|---|---| -| `admin` | `09120671756` | `ROLE_ADMIN` | — | -| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» | -| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` | -| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک | -| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری | -| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک | -| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» می‌بیند | -| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» | -| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک | -| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — | -| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت | -| `importer` | `09129000007` | `ROLE_IMPORTER` | — | - -`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ -ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» می‌گیرند. این باگ نیست: -`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد می‌کند. - -## شناسه‌ها - -| موجودیت | نام | UUID | -|---|---|---| -| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` | -| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` | -| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` | -| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` | -| پروفایل تصاحب‌نشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` | -| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` | -| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` | - -هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و -برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقه‌ای) -دارند، پس صفحات نوبت‌دهی‌شان خالی نیستند. - -## دادهٔ انبوه - -`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰ -نوبت می‌سازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست. - ---- - -## بازسازی از صفر - -ترتیب اجباری است — وابستگی‌ها چرخه‌ای‌اند: +کل این محیط با **یک دستور** ساخته می‌شود: ```bash -ddev exec php bin/console doctrine:schema:drop --full-database --force -ddev exec php bin/console doctrine:migrations:migrate --no-interaction -ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234' -# نماینده‌ها باید قبل از شهرها باشند: data/seed/cities.json به representation_id -# های ۱ تا ۳ ارجاع می‌دهد و app:seed-categories اعتبارسنجی‌شان می‌کند. -# POST /api/v1/representation ×۳ (با توکن ادمین) -ddev exec php bin/console app:seed-categories --no-interaction -ddev exec php bin/console app:seed-sms-message-templates --no-interaction -ddev exec php bin/console app:seed-demo-data --purge --no-interaction +ddev exec php bin/console app:seed-scenarios --reset -n ``` -`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نماینده‌های مرحلهٔ قبل را -purge و بازسازی می‌کند؛ بعد از آن `cities.representation_id` به شناسه‌های قدیمی اشاره -می‌کند و باید به شناسه‌های جدید نگاشت شود. +`--reset` دیتابیس را کامل خالی می‌کند، migrationها را از صفر می‌زند، دادهٔ پایه +(نماینده‌ها → دسته‌بندی‌ها → کاتالوگ بیمه → خاموش‌کردن کپچا) را می‌سازد و بعد سه سناریو +را می‌چیند. بدون `--reset` فقط سناریوها ساخته می‌شوند و روی دیتابیسی که قبلاً seed شده +با خطا برمی‌گردد. -سپس پرسوناها از راه اندپوینت‌های خود اپ ساخته می‌شوند: -`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` · -`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` · -`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` · -`send-code → verify-code → register` برای `patient`. +> **UUIDها با هر اجرا عوض می‌شوند.** جدول‌های زیر شکل داده را نشان می‌دهند نه شناسه‌های +> ثابت؛ برای گرفتن uuidهای فعلی از خود API یا دیتابیس بپرسید. -`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش -در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient` -را می‌شناسد، پس این دو با SQL مستقیم ست می‌شوند. +--- + +## سناریوی ۱ — پزشک مستقل، نوبت‌دهی سرویسی + +| نقش | موبایل | توضیح | +|---|---|---| +| پزشک | `0912000101` | سارا مرادی — مطب شخصی، بدون کلینیک | +| منشی | `0912000109` | منشیِ همان پزشک | +| بیماران | `09120001100` … `09120001104` | ۵ بیمار با پرونده | + +- **حالت نوبت‌دهی:** `service` · بافر ۱۰ دقیقه · شنبه تا چهارشنبه ۰۹:۰۰–۱۷:۰۰ +- **سرویس‌ها:** مشاوره پوست (۲۰د) · لیزر صورت (۳۰د، additional ۲۰) · تزریق ژل (۴۵د) · + میکرونیدلینگ (۶۰د، additional ۴۰) +- **بیمه:** تامین اجتماعی، بیمه ایران +- **نوبت‌ها:** ۹ نوبت در ۵ وضعیت (انجام‌شده، لغو کاربر، عدم حضور، تأییدشده، در انتظار پرداخت) + که دو تای آن‌ها **امروز** است + +## سناریوی ۲ — پزشکی که مالک کلینیک است + +| نقش | موبایل | توضیح | +|---|---|---| +| پزشک + مالک کلینیک | `0912000201` | امیر کاظمی — مالک «کلینیک تخصصی مهر»، خودش هم در کلینیک نوبت می‌دهد | +| پزشک عضو (سرویسی) | `0912000202` | نگار سلطانی | +| پزشک عضو (اسلاتی) | `0912000203` | بهرام فتحی | +| پزشک عضو (اسلاتی) | `0912000204` | الهام قاسمی | +| منشی | `0912000209` | روی هر ۴ پزشک | +| بیماران | `09120001200` … `09120001207` | ۸ بیمار | + +- **ترکیب حالت‌ها:** ۲ سرویسی + ۲ اسلاتی، همه شنبه تا چهارشنبه ۱۶:۰۰–۲۱:۰۰ +- **دستگاه‌ها:** ۲ لیزر (الکساندرایت، دایود) با setup/cleanup و تقویم کاری + ۲ اتاق درمان +- **سرویس‌ها:** لیزر کامل بدن (۹۰د) · لیزر زیربغل (۲۰د) · ویزیت پوست (۱۵د) · هیدرودرم (۴۵د) +- **وابستگی به دستگاه:** دو سرویس لیزری هرکدام یک بخش (`SegmentTemplate`) با نیازمندی + انحصاری روی نوع منبع «دستگاه لیزر» دارند +- **بیمه:** تامین اجتماعی، سلامت ایرانیان، بیمه آسیا + +این حساب **دو محیط کاری** دارد (مطب شخصی + کلینیک). سرویس‌ها و دستگاه‌ها زیر محیط +*کلینیک* اند، پس تا وقتی محیط عوض نشود لیستشان خالی است — این درست است، نه باگ: + +```bash +POST /api/v1/auth/switch-context {"db_uuid": ""} +``` + +## سناریوی ۳ — کلینیک مستقل با مالکِ غیرپزشک + +| نقش | موبایل | توضیح | +|---|---|---| +| مالک کلینیک | `0912000301` | رضا شریفی — **پزشک نیست**، فقط `ROLE_CLINIC` | +| پزشک (اسلاتی) | `0912000302` | پیمان اکبری | +| پزشک (سرویسی) | `0912000303` | مینا یوسفی | +| پزشک (منبع‌محور) | `0912000304` | آرش نوری | +| منشی | `0912000309` | روی هر ۳ پزشک | +| بیماران | `09120001300` … `09120001307` | ۸ بیمار | + +- **هر سه حالت نوبت‌دهی** اینجا زنده‌اند: `slot`، `service` و `resource` +- **ساعت کاری:** شنبه تا پنجشنبه ۰۸:۰۰–۱۴:۰۰ +- **دستگاه‌ها:** ۳ لیزر (CO2 فرکشنال، NdYAG، دایود) + دستگاه RF + دستگاه کرایو +- **سرویس‌ها:** ویزیت عمومی (۱۵د) · لیزر CO2 (۴۰د) · کرایوتراپی (۲۵د) · RF فرکشنال (۵۰د) +- **بیمه:** تامین اجتماعی، سلامت ایرانیان، بیمه دی + +مالکِ غیرپزشک عمدی است: هر مسیری که فرض کند مالکِ کلینیک یک `Doctor` دارد، باید +همین‌جا بشکند نه در محیط واقعی. + +--- + +## چه چیزی با این داده قابل تست است + +| قابلیت | کجا | +|---|---| +| نوبت‌دهی اسلاتی | `0912000203` · `0912000204` · `0912000302` | +| نوبت‌دهی سرویسی | `0912000101` · `0912000201` · `0912000202` · `0912000303` | +| نوبت‌دهی منبع‌محور | `0912000304` (با تخصیص واقعی دستگاه) | +| مطب شخصی در برابر کلینیک | سناریوی ۲ (یک کاربر، دو محیط) | +| مالک پزشک در برابر مالک اداری | سناریوی ۲ در برابر سناریوی ۳ | +| دستگاه و وابستگی سرویس به دستگاه | سناریوی ۲ و ۳ | +| بیمه (پایه و تکمیلی، فرانشیز، سقف) | هر سه محیط | +| پرداخت | ۳۲ پرداخت موفق روی نوبت‌های پرداخت‌شده | +| پرونده بیمار | ۲۱ پرونده با کد ملی روی پروفایل | ## نکته‌ها -- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و - ثبت‌نام اسکریپتی رد می‌شود. از پنل ادمین یا - `PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن. -- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`). -- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد.** برای تست‌های انبوه توکن را - مستقیم بساز: - `ddev exec 'php bin/console lexik:jwt:generate-token --user-class="App\\Auth\\Entity\\User"'` -- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'` - و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors` - یا `/api/v1/admin/clinic` ساخته می‌شوند رمز نمی‌گیرند. - -## نام پزشک بدون عنوان - -نام ذخیره‌شدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با -`PersianText::stripDoctorTitle()` حذف می‌کنند. افزودن عنوان کار لایهٔ نمایش است — -`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده می‌کند (idempotent)، -و پنل ادمین اصلاً عنوان اضافه نمی‌کند. - -پاک‌سازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run` +- **کپچا:** روی دیتابیس تازه پیش‌فرضش **روشن** است و هیچ‌کس نمی‌تواند وارد شود. + سیدر ردیف `site_config.altcha_enabled = '0'` را می‌گذارد تا محیط قابل ورود بماند. +- **لاگین با رمز فقط برای کارکنان است.** فیلد درخواست `mobile_number` است نه `mobile`: + `POST /api/v1/user/login {"mobile_number": "...", "password": "..."}`. + بیماران (`ROLE_USER`) عمداً فقط با OTP وارد می‌شوند + (`send-code` → `verify-code` با `12345` → `otp-login`). +- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد.** +- **نام پزشک بدون عنوان ذخیره می‌شود** — «دکتر» را لایهٔ نمایش اضافه می‌کند + (`PersianText::stripDoctorTitle`). +- **دادهٔ انبوه** جداست: `app:seed-demo-data` حدود ۵۰۰ پزشک و ۲۰هزار نوبت با INSERT خام + می‌سازد و برای تست کارایی و ماژول نمایندگان است، نه برای تست سناریویی. diff --git a/config/services.yaml b/config/services.yaml index e1c93b6b..f6ceb073 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -49,6 +49,10 @@ services: arguments: $environment: '%kernel.environment%' + App\Shared\Command\SeedScenariosCommand: + arguments: + $environment: '%kernel.environment%' + App\Shared\Service\FileUploadService: arguments: $projectDir: '%kernel.project_dir%' diff --git a/src/Shared/Command/SeedScenariosCommand.php b/src/Shared/Command/SeedScenariosCommand.php new file mode 100644 index 00000000..6344bf28 --- /dev/null +++ b/src/Shared/Command/SeedScenariosCommand.php @@ -0,0 +1,755 @@ + نقش، موبایل، توضیح */ + private array $report = []; + + public function __construct( + private readonly ManagerRegistry $registry, + private readonly TenantInsuranceService $insurances, + private readonly string $environment, + // hasherِ کانتینر، نه factory دستی: الگوریتم از security.yaml می‌آید و + // hashی که خودمان بسازیم ممکن است با چیزی که لاگین می‌سنجد یکی نباشد. + private readonly UserPasswordHasherInterface $hasher, + ) { + parent::__construct(); + } + + /** + * EntityManager از رجیستری گرفته می‌شود، نه تزریق مستقیم. + * + * `--reset` وسط کار migration اجرا می‌کند و آن، اتصال و savepointهای EMِ باز را + * بی‌اعتبار می‌کند؛ نگه‌داشتن یک نمونهٔ ثابت یعنی ادامهٔ کار روی managerِ بسته. + */ + private function em(): \Doctrine\ORM\EntityManagerInterface + { + /** @var \Doctrine\ORM\EntityManagerInterface $manager */ + $manager = $this->registry->getManager(); + + return $manager; + } + + protected function configure(): void + { + $this + ->addOption('reset', null, InputOption::VALUE_NONE, '⚠ کل دیتابیس را خالی می‌کند و migrationها را از صفر اجرا می‌کند، بعد seed می‌کند') + ->addOption('fresh', null, InputOption::VALUE_NONE, 'ساخت دادهٔ پایه (نماینده، دسته‌بندی‌ها، کاتالوگ بیمه) پیش از سناریوها — برای دیتابیس خالی') + ->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('این سیدر برای محیط تست است؛ روی prod فقط با --force اجرا می‌شود.'); + + return Command::FAILURE; + } + + $fresh = (bool) $input->getOption('fresh'); + + if ($input->getOption('reset')) { + $io->section('پاک‌سازی کامل دیتابیس'); + $this->resetDatabase($io); + $fresh = true; // دیتابیس خالی بدون دادهٔ پایه به‌درد نمی‌خورد. + } + + if ($this->alreadySeeded()) { + $io->error(sprintf('داده‌ی سناریوها از قبل هست (موبایل %sxxxx). دیتابیس را پاک کنید یا همان را استفاده کنید.', self::MOBILE_PREFIX)); + + return Command::FAILURE; + } + + if ($fresh) { + $io->section('دادهٔ پایه'); + $this->seedBase($io); + } + + $insuranceIds = $this->insuranceCatalog(); + + $io->section('سناریوی ۱ — پزشک مستقل (سرویسی)'); + $this->scenarioIndependentDoctor($insuranceIds); + + $io->section('سناریوی ۲ — پزشکِ مالکِ کلینیک'); + $this->scenarioDoctorOwnedClinic($insuranceIds); + + $io->section('سناریوی ۳ — کلینیک مستقل (مالک غیرپزشک)'); + $this->scenarioStandaloneClinic($insuranceIds); + + $this->em()->flush(); + + $io->success('سه سناریو ساخته شد. پسورد همهٔ حساب‌ها: ' . self::PASSWORD); + $io->table(['نقش', 'موبایل', 'توضیح'], $this->report); + + return Command::SUCCESS; + } + + private function alreadySeeded(): bool + { + return (bool) $this->em()->getConnection()->fetchOne( + 'SELECT 1 FROM users WHERE mobile_number LIKE ? LIMIT 1', + [self::MOBILE_PREFIX . '%'], + ); + } + + /** + * حذف کل schema و اجرای دوبارهٔ migrationها. + * + * `--purge`ِ انتخابی نداریم چون داده‌ی سناریوها به‌هم گره خورده است: کاربر، پرونده، + * نوبت، پرداخت و قرارداد بیمه با کلید خارجی به هم وصل‌اند و حذفِ نیمه‌کاره محیطی + * می‌سازد که هیچ‌وقت واقعی نبوده. یا همه‌چیز، یا هیچ‌چیز. + */ + private function resetDatabase(SymfonyStyle $io): void + { + foreach ([ + ['doctrine:schema:drop', ['--full-database' => true, '--force' => true]], + ['doctrine:migrations:migrate', ['--no-interaction' => true]], + ] as [$name, $args]) { + $buffer = new BufferedOutput(); + $command = $this->getApplication()?->find($name); + + // `--no-interaction` داخل ArrayInput کافی نیست: هر sub-command خودش + // `isInteractive()` را می‌پرسد و پیش‌فرضش true است، پس migration منتظر + // تأییدی می‌ماند که هیچ‌وقت نمی‌آید و اجرا بی‌صدا معلق می‌شود. + $subInput = new ArrayInput($args); + $subInput->setInteractive(false); + + $code = $command?->run($subInput, $buffer) ?? Command::FAILURE; + + if ($code !== Command::SUCCESS) { + throw new \RuntimeException(sprintf('%s شکست خورد: %s', $name, trim($buffer->fetch()))); + } + $io->writeln(' ' . $name . ' ✓'); + } + + // migration اتصال را زیر پای EM عوض می‌کند؛ بدون این، اولین persist روی + // savepointی می‌نشیند که دیگر وجود ندارد. + $this->em()->getConnection()->close(); + $this->registry->resetManager(); + } + + // ── دادهٔ پایه ─────────────────────────────────────────────────────────── + + /** + * نماینده‌ها **پیش از** شهرها ساخته می‌شوند: `cities.json` به شناسهٔ نماینده ارجاع + * می‌دهد و `CategoryImporter` نبودشان را خطای اعتبارسنجی می‌داند، پس روی دیتابیس + * خالی seed دسته‌بندی‌ها بدون این‌ها اصلاً اجرا نمی‌شود. + */ + private function seedBase(SymfonyStyle $io): void + { + foreach ([ + ['09124000001', 'نمایندهٔ یزد', 'yazd-nobat.ir'], + ['09124000002', 'نمایندهٔ یاسوج', 'yasuj-nobat.ir'], + ['09124000003', 'نمایندهٔ تهران', 'tehran-nobat.ir'], + ] as [$mobile, $name, $domain]) { + $user = $this->user($mobile, $name, ['ROLE_USER', 'ROLE_REPRESENTATION']); + $rep = new Representation($user, $name); + $rep->setDomain($domain)->setActive(true)->setCommissionPercent('10.00'); + $this->em()->persist($rep); + } + $this->em()->flush(); + + // بدون این ردیف، `AltchaService` روی پیش‌فرضِ «روشن» می‌افتد و روی دیتابیسِ تازه + // هیچ‌کس نمی‌تواند وارد شود — نه پنل، نه سایت. محیط تست باید قابل ورود باشد. + $this->em()->getConnection()->executeStatement( + 'INSERT INTO site_config (config_key, config_value, updated_at) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE config_value = VALUES(config_value), updated_at = VALUES(updated_at)', + ['altcha_enabled', '0', time()], + ); + + $categories = $this->getApplication()?->find('app:seed-categories'); + $buffer = new BufferedOutput(); + $code = $categories?->run(new ArrayInput([]), $buffer) ?? Command::FAILURE; + $io->writeln($code === Command::SUCCESS ? ' دسته‌بندی‌ها seed شد' : ' ⚠ seed دسته‌بندی‌ها: ' . trim($buffer->fetch())); + } + + /** @return array نام بیمه → شناسه */ + private function insuranceCatalog(): array + { + $ids = []; + foreach (self::INSURANCES as [$name, $type]) { + $existing = $this->em()->getRepository(Insurance::class)->findOneBy(['name' => $name]); + if ($existing === null) { + $existing = new Insurance($name, $type); + $this->em()->persist($existing); + } + $ids[$name] = 0; // پس از flush پر می‌شود + } + $this->em()->flush(); + + foreach (array_keys($ids) as $name) { + $ids[$name] = (int) $this->em()->getRepository(Insurance::class)->findOneBy(['name' => $name])?->getId(); + } + + return $ids; + } + + // ── سناریوی ۱: پزشک مستقل، نوبت‌دهی سرویسی ─────────────────────────────── + + /** @param array $insuranceIds */ + private function scenarioIndependentDoctor(array $insuranceIds): void + { + // نام پزشک بدون عنوان ذخیره می‌شود؛ «دکتر» کار لایهٔ نمایش است + // ({@see \App\Shared\Service\PersianText::stripDoctorTitle()}). + $user = $this->user(self::MOBILE_PREFIX . '101', 'سارا مرادی', ['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = $this->doctor($user, 'سارا مرادی', 'woman', '100101'); + $this->report[] = ['۱ · پزشک مستقل (سرویسی)', self::MOBILE_PREFIX . '101', 'دکتر سارا مرادی — پوست و مو']; + + $address = $this->address(DoctorAddress::forDoctor($doctor), 'مطب دکتر مرادی', 'یاسوج، خیابان شهید مطهری، ساختمان پزشکان، طبقه ۲'); + + $this->schedule($doctor, null, $address, WeeklySchedule::MODE_SERVICE, days: [0, 1, 2, 3, 4], from: '09:00', to: '17:00', buffer: 10); + + $section = $this->section('doctor', $doctor->getId(), 'خدمات پوست'); + $services = [ + $this->service($section, 'مشاوره پوست', 20, 1_500_000), + $this->service($section, 'لیزر صورت', 30, 4_500_000, additional: 20), + $this->service($section, 'تزریق ژل', 45, 12_000_000), + $this->service($section, 'میکرونیدلینگ', 60, 8_000_000, additional: 40), + ]; + + $this->activateInsurances('doctor', $doctor->getId(), $insuranceIds, ['تامین اجتماعی', 'بیمه ایران']); + + $secretary = $this->user(self::MOBILE_PREFIX . '109', 'منشی دکتر مرادی', ['ROLE_USER', 'ROLE_SECRETARY']); + $this->em()->persist(new DoctorSecretary($doctor, $secretary)); + $this->report[] = ['۱ · منشی', self::MOBILE_PREFIX . '109', 'منشی دکتر مرادی']; + + $patients = $this->patients('doctor', $doctor->getId(), $doctor->getId(), '11', 5); + $this->appointmentsFor($doctor, null, $address, $patients, $services, serviceMode: true, openHour: 9); + } + + // ── سناریوی ۲: پزشکی که مالک کلینیک است ───────────────────────────────── + + /** @param array $insuranceIds */ + private function scenarioDoctorOwnedClinic(array $insuranceIds): void + { + $ownerUser = $this->user(self::MOBILE_PREFIX . '201', 'امیر کاظمی', ['ROLE_USER', 'ROLE_DOCTOR', 'ROLE_CLINIC']); + $owner = $this->doctor($ownerUser, 'امیر کاظمی', 'man', '100201'); + + $clinic = new Clinic($ownerUser); + $clinic->setName('کلینیک تخصصی مهر')->setIsActive(true); + $this->em()->persist($clinic); + $this->em()->flush(); + + $this->report[] = ['۲ · پزشکِ مالکِ کلینیک', self::MOBILE_PREFIX . '201', 'دکتر امیر کاظمی — مالک «کلینیک تخصصی مهر»']; + + $address = $this->address(DoctorAddress::forClinic($clinic->getId()), 'کلینیک تخصصی مهر', 'یاسوج، بلوار امام، نبش کوچه ۱۲'); + + // مالک هم خودش در کلینیک نوبت می‌دهد — همان حالتی که مالکِ پزشک را از مالکِ اداری جدا می‌کند. + $clinic->getDoctors()->add($owner); + + $members = [ + [self::MOBILE_PREFIX . '202', 'نگار سلطانی', 'woman', '100202', WeeklySchedule::MODE_SERVICE], + [self::MOBILE_PREFIX . '203', 'بهرام فتحی', 'man', '100203', WeeklySchedule::MODE_SLOT], + [self::MOBILE_PREFIX . '204', 'الهام قاسمی', 'woman', '100204', WeeklySchedule::MODE_SLOT], + ]; + + $doctors = [[$owner, WeeklySchedule::MODE_SERVICE]]; + foreach ($members as [$mobile, $name, $gender, $code, $mode]) { + $u = $this->user($mobile, $name, ['ROLE_USER', 'ROLE_DOCTOR']); + $d = $this->doctor($u, $name, $gender, $code); + $clinic->getDoctors()->add($d); + $doctors[] = [$d, $mode]; + $this->report[] = ['۲ · پزشک کلینیک — ' . ($mode === WeeklySchedule::MODE_SERVICE ? 'سرویسی' : 'اسلاتی'), $mobile, $name]; + } + + foreach ($doctors as [$d, $mode]) { + $this->schedule($d, $clinic, $address, $mode, days: [0, 1, 2, 3, 4], from: '16:00', to: '21:00', buffer: $mode === WeeklySchedule::MODE_SERVICE ? 5 : 0); + } + + // دستگاه‌ها: نوعِ منبع در سطح محیط تعریف می‌شود و دستگاه‌ها زیر آدرس می‌نشینند. + $laserType = $this->resourceType('clinic', $clinic->getId(), 'laser', 'دستگاه لیزر'); + $roomType = $this->resourceType('clinic', $clinic->getId(), 'room', 'اتاق درمان'); + $lasers = [ + $this->resource($address, $laserType, 'لیزر الکساندرایت ۱', setup: 5, cleanup: 10), + $this->resource($address, $laserType, 'لیزر دایود ۲', setup: 5, cleanup: 10), + ]; + $this->resource($address, $roomType, 'اتاق ۱'); + $this->resource($address, $roomType, 'اتاق ۲'); + foreach ($lasers as $laser) { + $this->resourceHours($laser, [0, 1, 2, 3, 4], 16 * 60, 21 * 60); + } + + $section = $this->section('clinic', $clinic->getId(), 'خدمات لیزر و زیبایی'); + $services = [ + $this->service($section, 'لیزر کامل بدن', 90, 25_000_000, additional: 60), + $this->service($section, 'لیزر زیربغل', 20, 3_000_000, additional: 10), + $this->service($section, 'ویزیت پوست', 15, 2_000_000), + $this->service($section, 'هیدرودرم', 45, 9_000_000), + ]; + + // دو سرویس لیزری به دستگاه لیزر نیاز دارند — همین چیزی است که برنامهٔ چندبخشی می‌سنجد. + $this->requireResource($services[0], $laserType, 'لیزر', 90); + $this->requireResource($services[1], $laserType, 'لیزر', 20); + + $this->activateInsurances('clinic', $clinic->getId(), $insuranceIds, ['تامین اجتماعی', 'سلامت ایرانیان', 'بیمه آسیا']); + + $secretary = $this->user(self::MOBILE_PREFIX . '209', 'منشی کلینیک مهر', ['ROLE_USER', 'ROLE_SECRETARY']); + foreach ($clinic->getDoctors() as $d) { + $this->em()->persist(new DoctorSecretary($d, $secretary, $clinic)); + } + $this->report[] = ['۲ · منشی کلینیک', self::MOBILE_PREFIX . '209', 'روی هر ۴ پزشک']; + + $patients = $this->patients('clinic', $clinic->getId(), $owner->getId(), '12', 8); + foreach ($doctors as [$d, $mode]) { + $this->appointmentsFor($d, $clinic, $address, $patients, $services, serviceMode: $mode === WeeklySchedule::MODE_SERVICE, openHour: 16); + } + } + + // ── سناریوی ۳: کلینیک مستقل با مالک غیرپزشک ───────────────────────────── + + /** @param array $insuranceIds */ + private function scenarioStandaloneClinic(array $insuranceIds): void + { + $ownerUser = $this->user(self::MOBILE_PREFIX . '301', 'مدیر رضا شریفی', ['ROLE_USER', 'ROLE_CLINIC']); + + $clinic = new Clinic($ownerUser); + $clinic->setName('درمانگاه شبانه‌روزی سلامت')->setIsActive(true); + $this->em()->persist($clinic); + $this->em()->flush(); + + // مالک عمداً پزشک **نیست**: مسیرهایی که فرض می‌کنند مالکِ کلینیک یک Doctor دارد + // باید همین‌جا بشکنند، نه در محیط واقعی. + $this->report[] = ['۳ · مالک کلینیک (غیرپزشک)', self::MOBILE_PREFIX . '301', 'مدیر رضا شریفی — «درمانگاه شبانه‌روزی سلامت»']; + + $address = $this->address(DoctorAddress::forClinic($clinic->getId()), 'درمانگاه سلامت', 'یاسوج، میدان معلم، ابتدای خیابان دانشجو'); + + $members = [ + [self::MOBILE_PREFIX . '302', 'پیمان اکبری', 'man', '100302', WeeklySchedule::MODE_SLOT], + [self::MOBILE_PREFIX . '303', 'مینا یوسفی', 'woman', '100303', WeeklySchedule::MODE_SERVICE], + // سومین حالت نوبت‌دهی هم یک نمونهٔ زنده لازم دارد، وگرنه فقط در تست‌ها دیده می‌شود. + [self::MOBILE_PREFIX . '304', 'آرش نوری', 'man', '100304', WeeklySchedule::MODE_RESOURCE], + ]; + + $doctors = []; + foreach ($members as [$mobile, $name, $gender, $code, $mode]) { + $u = $this->user($mobile, $name, ['ROLE_USER', 'ROLE_DOCTOR']); + $d = $this->doctor($u, $name, $gender, $code); + $clinic->getDoctors()->add($d); + $doctors[] = [$d, $mode]; + $this->schedule($d, $clinic, $address, $mode, days: [0, 1, 2, 3, 4, 5], from: '08:00', to: '14:00', buffer: $mode === WeeklySchedule::MODE_SERVICE ? 10 : 0); + $this->report[] = ['۳ · پزشک درمانگاه — ' . ($mode === WeeklySchedule::MODE_SERVICE ? 'سرویسی' : 'اسلاتی'), $mobile, $name]; + } + + $laserType = $this->resourceType('clinic', $clinic->getId(), 'laser', 'دستگاه لیزر'); + $deviceType = $this->resourceType('clinic', $clinic->getId(), 'device', 'دستگاه تخصصی'); + $lasers = [ + $this->resource($address, $laserType, 'لیزر CO2 فرکشنال', setup: 10, cleanup: 15), + $this->resource($address, $laserType, 'لیزر NdYAG', setup: 5, cleanup: 10), + $this->resource($address, $laserType, 'لیزر دایود ۳', setup: 5, cleanup: 5), + ]; + $this->resource($address, $deviceType, 'دستگاه RF'); + $this->resource($address, $deviceType, 'دستگاه کرایو'); + foreach ($lasers as $laser) { + $this->resourceHours($laser, [0, 1, 2, 3, 4, 5], 8 * 60, 14 * 60); + } + + $section = $this->section('clinic', $clinic->getId(), 'خدمات درمانگاه'); + $services = [ + $this->service($section, 'ویزیت عمومی', 15, 1_200_000), + $this->service($section, 'لیزر CO2', 40, 15_000_000, additional: 25), + $this->service($section, 'کرایوتراپی', 25, 5_500_000), + $this->service($section, 'RF فرکشنال', 50, 11_000_000, additional: 30), + ]; + $this->requireResource($services[1], $laserType, 'لیزر', 40); + $this->requireResource($services[3], $deviceType, 'RF', 50); + + $this->activateInsurances('clinic', $clinic->getId(), $insuranceIds, ['تامین اجتماعی', 'سلامت ایرانیان', 'بیمه دی']); + + $secretary = $this->user(self::MOBILE_PREFIX . '309', 'منشی درمانگاه سلامت', ['ROLE_USER', 'ROLE_SECRETARY']); + foreach ($clinic->getDoctors() as $d) { + $this->em()->persist(new DoctorSecretary($d, $secretary, $clinic)); + } + $this->report[] = ['۳ · منشی درمانگاه', self::MOBILE_PREFIX . '309', 'روی هر ۳ پزشک']; + + $patients = $this->patients('clinic', $clinic->getId(), $doctors[0][0]->getId(), '13', 8); + foreach ($doctors as [$d, $mode]) { + $this->appointmentsFor($d, $clinic, $address, $patients, $services, serviceMode: $mode === WeeklySchedule::MODE_SERVICE, openHour: 8); + } + } + + // ── سازنده‌های مشترک ───────────────────────────────────────────────────── + + /** @param string[] $roles */ + private function user(string $mobile, string $name, array $roles): User + { + $user = new User($mobile); + $user->setPasswordHash($this->hasher->hashPassword($user, self::PASSWORD)) + ->setRealName($name) + ->setRoles($roles) + ->setStatus(1); + $this->em()->persist($user); + $this->em()->flush(); + + return $user; + } + + private function doctor(User $user, string $name, string $gender, string $code): Doctor + { + $doctor = new Doctor($user, $name); + $doctor->setGender($gender) + ->setMedicalSystemCode($code) + ->setMobileNumber($user->getMobileNumber()) + ->setActiveDoctorAppointment(true); + + $city = $this->em()->getRepository(City::class)->find(self::CITY_ID); + if ($city !== null) { + $doctor->getCities()->add($city); + } + + $this->em()->persist($doctor); + $this->em()->flush(); + + return $doctor; + } + + private function address(DoctorAddress $address, string $name, string $street): DoctorAddress + { + $address->setName($name) + ->setAddress($street) + ->setTelephone('074' . random_int(30000000, 39999999)) + ->setLatitude(30.6669) + ->setLongitude(51.5801) + ->setActive(true); + + $city = $this->em()->getRepository(City::class)->find(self::CITY_ID); + if ($city !== null) { + $address->setCity($city); + $address->setProvince($city->getProvince()); + } + + $this->em()->persist($address); + $this->em()->flush(); + + return $address; + } + + /** + * برنامهٔ هفتگی با همان شکلی که کد واقعی می‌خواند: کلید ۰ تا ۶ (شنبه..جمعه) با + * `sessions`، و `meta` که حالت نوبت‌دهی و بافر را نگه می‌دارد. + * + * @param int[] $days + */ + private function schedule(Doctor $doctor, ?Clinic $clinic, DoctorAddress $address, string $mode, array $days, string $from, string $to, int $buffer): WeeklySchedule + { + $setting = []; + for ($day = 0; $day <= 6; $day++) { + $setting[(string) $day] = ['sessions' => in_array($day, $days, true) ? [[ + 'active' => true, + 'location_id' => $address->getId(), + 'start_time' => $from, + 'end_time' => $to, + 'duration_per_patient' => 20, + 'has_rest' => false, + 'rest_interval' => 60, + 'time_to_rest' => 10, + 'patient_limit' => null, + ]] : []]; + } + + $setting['meta'] = [ + 'online_booking_enabled' => true, + 'booking_window_value' => 2, + 'booking_window_unit' => 'month', + 'booking_mode' => $mode, + 'buffer_minutes' => $buffer, + // فقط حالت منبع‌محور می‌خواندشان؛ گذاشتنشان برای بقیه بی‌اثر است. + 'step_minutes' => 15, + 'resource_strategy' => 'first_available', + ]; + + $schedule = new WeeklySchedule($doctor, $setting, $clinic); + $schedule->assignTenant(EntityContext::forBooking($doctor, $clinic)); + $this->em()->persist($schedule); + $this->em()->flush(); + + return $schedule; + } + + private function section(string $entityType, int $entityId, string $name): ServiceSection + { + $section = new ServiceSection($entityType, $entityId, $name); + $this->em()->persist($section); + $this->em()->flush(); + + return $section; + } + + private function service(ServiceSection $section, string $name, int $minutes, int $priceRials, ?int $additional = null): ServiceItem + { + $item = new ServiceItem($section, $name, $priceRials); + $item->setDurationMinutes($minutes) + ->setBookable(true) + ->setActive(true) + ->setInsuranceCovered(true) + ->setServiceCategory(ServiceCategory::Outpatient); + + // مدتِ «کنار بقیه» فقط روی سرویس‌هایی که واقعاً با هم انجام می‌شوند معنا دارد؛ + // بدون این، تفاوت solo/additional در هیچ سناریویی دیده نمی‌شود. + if ($additional !== null) { + $item->setAdditionalDurationMinutes($additional); + } + + $this->em()->persist($item); + $this->em()->flush(); + + return $item; + } + + private function resourceType(string $entityType, int $entityId, string $code, string $name): ResourceType + { + $type = new ResourceType($entityType, $entityId, $code, $name); + $this->em()->persist($type); + $this->em()->flush(); + + return $type; + } + + private function resource(DoctorAddress $address, ResourceType $type, string $name, int $setup = 0, int $cleanup = 0): ClinicResource + { + $resource = new ClinicResource($address, $type, $name); + $resource->setSetupMinutes($setup)->setCleanupMinutes($cleanup)->setActive(true); + $this->em()->persist($resource); + $this->em()->flush(); + + return $resource; + } + + /** @param int[] $days */ + private function resourceHours(ClinicResource $resource, array $days, int $startMinute, int $endMinute): void + { + foreach ($days as $day) { + $this->em()->persist(new ResourceCalendar($resource, $day, $startMinute, $endMinute)); + } + $this->em()->flush(); + } + + /** سرویسی که بدون دستگاه انجام نمی‌شود: یک بخش با یک نیازمندیِ انحصاری. */ + private function requireResource(ServiceItem $service, ResourceType $type, string $segmentName, int $minutes): void + { + $segment = new SegmentTemplate($service, 1, $segmentName); + $segment->setDuration(SegmentTemplate::DURATION_FIXED, $minutes); + $this->em()->persist($segment); + + $requirement = new SegmentRequirement($segment, $type, 1); + $requirement->setOccupancy(SegmentRequirement::OCCUPANCY_EXCLUSIVE); + $this->em()->persist($requirement); + $this->em()->flush(); + } + + /** @param array $catalog @param string[] $names */ + private function activateInsurances(string $entityType, int $entityId, array $catalog, array $names): void + { + foreach ($names as $i => $name) { + if (!isset($catalog[$name])) { + continue; + } + $this->insurances->activate( + $entityType, + $entityId, + $catalog[$name], + coveragePercent: [70.0, 85.0, 100.0][$i % 3], + franchisePercent: [0.0, 10.0, 20.0][$i % 3], + annualCeilingRials: $i === 0 ? null : 50_000_000, + ); + } + } + + /** + * بیمارها هم کاربرِ لاگین‌پذیرند هم پرونده دارند: بدون کاربر نمی‌شود از سایت + * عمومی واردشان شد و بدون پرونده در پنل پزشک دیده نمی‌شوند. + * + * @return User[] + */ + private function patients(string $entityType, int $entityId, int $createdById, string $group, int $count): array + { + $names = ['زهرا احمدی', 'محمد رضایی', 'فاطمه کریمی', 'علی حسینی', 'مریم موسوی', 'حسین جعفری', 'سارا صادقی', 'رضا رحیمی']; + + $patients = []; + for ($i = 0; $i < $count; $i++) { + $mobile = self::MOBILE_PREFIX . $group . str_pad((string) $i, 2, '0', STR_PAD_LEFT); + $user = $this->user($mobile, $names[$i % count($names)], ['ROLE_USER']); + + // کد ملی روی پروفایلِ کاربر می‌نشیند، نه روی پرونده — رزرو سایت عمومی هم از + // همان‌جا می‌خواندش، پس بدون پروفایل بیمار در فرم رزرو گیر می‌کند. + $profile = new UserProfile($user); + $profile->setNationalCode(str_pad((string) random_int(1_000_000_000, 9_999_999_999), 10, '0', STR_PAD_LEFT)); + $profile->setNationalCodeApproved(true); + $this->em()->persist($profile); + + $record = new PatientRecord($entityType, $entityId, $user, 'doctor', $createdById); + $this->em()->persist($record); + + $patients[] = $user; + } + $this->em()->flush(); + + $this->report[] = ['بیماران گروه ' . $group, self::MOBILE_PREFIX . $group . '00…', $count . ' بیمار با پرونده']; + + return $patients; + } + + /** + * مسیر رسیدن به یک وضعیت از `pending` — هر گام یک انتقالِ مجاز. + * + * @return string[] + */ + private function pathTo(string $status): array + { + return match ($status) { + Appointment::STATUS_PENDING => [], + Appointment::STATUS_COMPLETED, + Appointment::STATUS_NO_SHOW => [Appointment::STATUS_CONFIRMED, $status], + default => [$status], + }; + } + + /** + * نوبت‌ها عمداً در وضعیت‌های مختلف‌اند — گذشتهٔ انجام‌شده، لغوشده، در انتظار پرداخت، + * و آیندهٔ تأییدشده. لیستی که همه‌اش یک وضعیت داشته باشد، فیلترها و گزارش‌ها را تست نمی‌کند. + * + * @param User[] $patients + * @param ServiceItem[] $services + */ + private function appointmentsFor(Doctor $doctor, ?Clinic $clinic, DoctorAddress $address, array $patients, array $services, bool $serviceMode, int $openHour): void + { + // ساعت‌ها نسبت به شیفت همان محیط‌اند، نه عددِ ثابت: نوبتِ ۱۰ صبح برای کلینیکی که + // ۱۶ تا ۲۱ کار می‌کند، در تقویم پنل بیرون از ساعت کاری می‌افتد. + // + // دو ردیف **امروز** هست تا نمای پیش‌فرض پنل (که همیشه امروز را نشان می‌دهد) خالی + // نباشد — محیط تستی که با صفحهٔ خالی باز شود، اولین برداشت را خراب می‌کند. + $plan = [ + ['days' => -21, 'slot' => 0, 'status' => Appointment::STATUS_COMPLETED, 'paid' => true], + ['days' => -14, 'slot' => 1, 'status' => Appointment::STATUS_CANCELLED_BY_USER, 'paid' => false], + ['days' => -7, 'slot' => 2, 'status' => Appointment::STATUS_COMPLETED, 'paid' => true], + ['days' => 0, 'slot' => 0, 'status' => Appointment::STATUS_COMPLETED, 'paid' => true], + ['days' => 0, 'slot' => 3, 'status' => Appointment::STATUS_CONFIRMED, 'paid' => true], + ['days' => 1, 'slot' => 1, 'status' => Appointment::STATUS_NO_SHOW, 'paid' => true], + ['days' => 2, 'slot' => 0, 'status' => Appointment::STATUS_CONFIRMED, 'paid' => true], + ['days' => 4, 'slot' => 2, 'status' => Appointment::STATUS_PENDING, 'paid' => false], + ['days' => 6, 'slot' => 3, 'status' => Appointment::STATUS_CONFIRMED, 'paid' => true], + ]; + + foreach ($plan as $i => $row) { + $patient = $patients[$i % count($patients)]; + $service = $services[$i % count($services)]; + $minutes = $serviceMode ? (int) ($service->getSoloDurationMinutes() ?? 20) : 20; + + $hour = $openHour + $row['slot']; + $start = strtotime(sprintf('%+d days', $row['days']), strtotime(sprintf('today %d:00', $hour))); + // شروع‌ها را به دقیقه‌های متفاوت می‌بریم تا کلید یکتای (پزشک، شروع) برخورد نکند. + $start += $i * 5 * 60; + $end = $start + $minutes * 60; + + $appointment = new Appointment($doctor, $patient, $start, $end); + $appointment->setClinic($clinic); + $appointment->setAddressId($address->getId()); + $appointment->assignTenant(EntityContext::forBooking($doctor, $clinic)); + $appointment->setPatientName($patient->getRealName()); + $appointment->setPatientMobile($patient->getMobileNumber()); + $appointment->setPatientNationalCode(str_pad((string) random_int(1_000_000_000, 9_999_999_999), 10, '0', STR_PAD_LEFT)); + $appointment->setPatientGender('woman'); + $appointment->setVisitPriceRials($service->getPriceRials()); + + if ($serviceMode) { + $appointment->replaceServiceItems([$service]); + $appointment->setServiceDuration($minutes, 0); + } + + // ماشین وضعیت پرش نمی‌پذیرد: `completed` و `no_show` فقط از `confirmed` + // می‌آیند. seed هم از همان مسیر رد می‌شود تا داده‌ای نسازد که کد نمی‌سازد. + foreach ($this->pathTo($row['status']) as $step) { + $appointment->transitionTo($step); + } + $this->em()->persist($appointment); + + if ($row['paid']) { + $payment = new Payment($patient, $service->getPriceRials(), 'zarinpal', Payment::TYPE_APPOINTMENT, 'seed'); + $payment->setAppointment($appointment); + $payment->setStatus(Payment::STATUS_SUCCESS); + $payment->assignTenant(EntityContext::forBooking($doctor, $clinic)); + $this->em()->persist($payment); + } + } + + $this->em()->flush(); + } +}