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 خام
می‌سازد و برای تست کارایی و ماژول نمایندگان است، نه برای تست سناریویی.