نقش، موبایل، توضیح */ 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(); } }