Files
clinicpro/src/Shared/Command/SeedScenariosCommand.php
T
hamedandClaude Opus 5 369f3ae710 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>
2026-08-01 18:41:03 +03:30

756 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
}