feat(seed): one command that builds three complete, working environments

Manual testing had no environment to test in: the demo seeder builds volume
(500 doctors, 20k appointments via raw INSERT) for the representation module,
which is the wrong shape for walking through a scenario end to end.

app:seed-scenarios builds three environments that each work from login to
booking:

  1. an independent doctor, service mode, with services, schedule, insurance,
     patients and appointments
  2. a doctor who also owns a clinic, with three more doctors inside it, a
     slot/service mix, two laser devices and two rooms, and laser services that
     genuinely require a laser
  3. a clinic whose owner is not a doctor, with all three booking modes live
     (slot, service and resource), five devices, and the same full data set

Everything goes through entities and the real services rather than raw SQL, so
tenant pairs, the unique active-slot key and the insurance rules hold. The
status machine is walked step by step (completed only via confirmed) instead of
writing a status the application could never produce.

--reset drops the schema, re-runs migrations and seeds base data in one go.
Three things it has to handle, each found by it breaking:

- representations must exist before cities, because cities.json references them
  by id and the category importer validates that
- migrations run mid-process invalidate the EntityManager's connection, so the
  manager is taken from the registry and reset afterwards
- a sub-command's --no-interaction in ArrayInput is not enough; without
  setInteractive(false) the migration waits forever for a confirmation

It also writes site_config.altcha_enabled = '0'. On a freshly migrated database
the captcha defaults to on and nobody can log in at all — panel or site.

Verified against the running app, not just the database: booking-locations
reports the right mode per doctor, service slots respect the buffer, the
slot-mode doctor returns a session with 15 slots, the resource-mode doctor
returns 40 options each with a real device assignment, a patient booked a
service appointment through the public endpoint, and the admin panel renders
the seeded day for both clinic owners.

TEST_USERS.md is rewritten: every account it described was gone after the wipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 18:41:03 +03:30
co-authored by Claude Opus 5
parent cbff3f3e3e
commit 369f3ae710
3 changed files with 857 additions and 97 deletions
+98 -97
View File
@@ -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": "<clinic_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 <mobile> --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 خام
می‌سازد و برای تست کارایی و ماژول نمایندگان است، نه برای تست سناریویی.
+4
View File
@@ -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%'
+755
View File
@@ -0,0 +1,755 @@
<?php
namespace App\Shared\Command;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Insurance\Entity\Insurance;
use App\Insurance\Enum\InsuranceType;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Service\TenantInsuranceService;
use App\Location\Entity\City;
use App\Patient\Entity\PatientRecord;
use App\Payment\Entity\Payment;
use App\Representation\Entity\Representation;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceCalendar;
use App\Resource\Entity\ResourceType;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Context\EntityContext;
use App\UserProfile\Entity\UserProfile;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* سه محیطِ کامل و قابل‌کارکردن برای تست دستی — نه داده‌ی حجیم.
*
* تفاوتش با {@see SeedDemoDataCommand}: آن یکی حجم می‌سازد (۵۰۰ پزشک، ۲۰هزار نوبت با
* INSERT خام) تا کارایی و ماژول نمایندگان تست شود. این یکی **سناریو** می‌سازد: سه محیط
* که هر کدام از اول تا آخر کار می‌کنند — می‌شود واردشان شد، نوبت گرفت، سرویس و دستگاه و
* بیمه و پرداختشان را دید.
*
* همه چیز از Entity و سرویس‌های واقعی رد می‌شود، نه SQL خام: جفتِ محیط، کلید یکتای اسلات،
* و قواعد بیمه باید همان‌طور اعمال شوند که در زمان اجرا می‌شوند. داده‌ای که با INSERT
* دستی ساخته شود می‌تواند وضعیتی بسازد که کد هرگز نمی‌سازد و بعد ساعت‌ها دنبال باگی
* می‌گردی که وجود ندارد.
*
* سه سناریو:
* ۱. پزشک مستقل، نوبت‌دهی **سرویسی**
* ۲. پزشکی که خودش **مالک کلینیک** است + سه پزشک دیگر، ترکیب اسلاتی/سرویسی، دستگاه لیزر
* ۳. کلینیک مستقل با مالکِ **غیرپزشک** + سه پزشک، ترکیب حالت‌ها، دستگاه‌ها
*
* ddev exec php bin/console app:seed-scenarios --fresh
*/
#[AsCommand(
name: 'app:seed-scenarios',
description: 'Seed three complete, working environments (independent doctor, doctor-owned clinic, standalone clinic)',
)]
class SeedScenariosCommand extends Command
{
public const PASSWORD = 'QaTest@1234';
/** موبایل همهٔ حساب‌های این سیدر با این پیشوند شروع می‌شود — مبنای شناسایی. */
private const MOBILE_PREFIX = '0912000';
/** شهر پیش‌فرض سناریوها؛ دامنهٔ محلیِ سایت عمومی هم همین است. */
private const CITY_ID = 123; // یاسوج → yasuj-nobat.ir
private const INSURANCES = [
['تامین اجتماعی', InsuranceType::Basic],
['سلامت ایرانیان', InsuranceType::Basic],
['بیمه ایران', InsuranceType::Supplementary],
['بیمه آسیا', InsuranceType::Supplementary],
['بیمه دی', InsuranceType::Supplementary],
];
/** @var array<int, array{0:string,1:string,2:string}> نقش، موبایل، توضیح */
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<string, int> نام بیمه → شناسه */
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<string,int> $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<string,int> $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<string,int> $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<string,int> $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();
}
}