diff --git a/TEST_USERS.md b/TEST_USERS.md index 52d5470e..a755355b 100644 --- a/TEST_USERS.md +++ b/TEST_USERS.md @@ -82,6 +82,31 @@ POST /api/v1/auth/switch-context {"db_uuid": ""} --- +## موتور نوبت‌دهی جدید (تسک‌های `docs/new_feture`) + +هر سه محیط، علاوه بر داده‌های بالا، دادهٔ زندهٔ موتور جدید هم دارند: + +| تسک | چه چیزی seed می‌شود | کجا دیده می‌شود | +|---|---|---| +| ۰۱ شعبه و اتاق | `DoctorAddress` به‌عنوان شعبه + منبع اتاق با تقویم | `GET /api/v1/resources` | +| ۰۲ مدل منبع | نوع منبع، دستگاه، **مهارت** (`کار با لیزر`) با سطح، **استخر منبع** با اولویت | `GET /api/v1/resources` · `/skills` | +| ۰۳ تقویم منبع | تقویم هفتگی هر دستگاه + یک **استثنای تعمیرات** هفتهٔ بعد | جستجوی آزاد آن بازه را رد می‌کند | +| ۰۴ کاتالوگ نسل دوم | دستهٔ درختی، **گروه انتخاب** با بازهٔ ۱ تا ۲، **ناسازگاری** دو سرویس، **override شعبه**، مدت solo/additional | `GET /api/v1/service-items` | +| ۰۵ برنامهٔ چندبخشی | سرویس اصلی هر محیط سه بخش دارد: بی‌حسی ۵د (اتاق انحصاری) → انتظار ۳۰د (اتاق **passive**) → لیزر ۲۰د (اتاق + دستگاه) | `GET /api/v1/service-item/{uuid}/segments` | +| ۰۶ موتور دسترس‌پذیری | همان برنامه با منابع واقعی جستجو می‌شود | `POST /api/v1/appointment-availability` | +| ۰۷ نگه‌داشتن و ثبت | **۲ نوبت در هر محیط از مسیر واقعی** hold → confirm، با بخش و اشغال منبع | `GET /api/v1/appointment/{uuid}/segments` | +| ۰۸ عکس قیمت | لیست قیمت فعال + `PriceSnapshot` روی نوبت‌های واقعی با بیعانه | `GET /api/v1/price-lists` | +| ۰۹ موتور سیاست | **۶ سیاست، یکی از هر دسته** (انتخاب، صلاحیت، منبع، زمان، فاصله، قیمت) | `GET /api/v1/policies` | +| ۱۱ پکیج و دفتر اعتبار | پکیج ۶ جلسه‌ای + ۲ پکیج خریداری‌شده + ردیف مصرف در دفتر | `GET /api/v1/packages` | +| ۱۲ دورهٔ درمان | پروتکل ۶ جلسه‌ای با پارامتر انرژی هر جلسه + دورهٔ فعال با ۶ جلسه | `GET /api/v1/treatment-course/{uuid}` | +| ۱۳ لغو و لیست انتظار | سیاست عمومی + سیاست سخت‌گیرانه روی یک سرویس، رکورد عدم حضور، ۲ نفر در لیست انتظار | `GET /api/v1/waitlist` · `/cancellation-policy` | +| ۱۴ رویداد و بهره‌وری | `HoldCreated` و `AppointmentBooked` در outbox + دقیقهٔ اشغال واقعی هر منبع | `GET /api/v1/reports/resource-utilization?branch_uuid=…` | + +نوبت‌های موتور جدید **از `new Appointment(...)` ساخته نمی‌شوند**؛ از +`AppointmentPlanBuilder` → `AvailabilityEngine` → `HoldService` → `BookingService` رد +می‌شوند. برای همین اشغال منبع و رویداد دامنه واقعی‌اند: گزارش بهره‌وری عدد نشان می‌دهد و +تقویم دستگاه واقعاً پر است. + ## چه چیزی با این داده قابل تست است | قابلیت | کجا | diff --git a/src/Shared/Command/BookingEngineSeeder.php b/src/Shared/Command/BookingEngineSeeder.php new file mode 100644 index 00000000..b7001c31 --- /dev/null +++ b/src/Shared/Command/BookingEngineSeeder.php @@ -0,0 +1,503 @@ +registry->getManager(); + + return $manager; + } + + /** + * @param ServiceItem[] $services + * @param ClinicResource[] $devices دستگاه‌های همان محیط + * @param User[] $patients + * + * @return array شمارش آنچه ساخته شد، برای گزارش دستور + */ + public function seed( + string $entityType, + int $entityId, + DoctorAddress $address, + array $services, + array $devices, + array $patients, + ResourceType $deviceType, + \App\Doctor\Entity\Doctor $doctor, + ?\App\Clinic\Entity\Clinic $clinic, + ): array { + $counts = []; + + $flagship = $services[0]; + + $counts['catalog'] = $this->catalog($entityType, $entityId, $services, $address); + $counts['resources'] = $this->skillsPoolsAndExceptions($entityType, $entityId, $address, $devices, $deviceType); + $counts['segments'] = $this->multiSegmentPlan($flagship, $deviceType, $entityType, $entityId, $address); + $counts['pricing'] = $this->priceList($entityType, $entityId, $services); + $counts['policies'] = $this->policies($entityType, $entityId, $services); + $counts['booked'] = $this->realBookings($flagship, $address, $patients, $doctor, $clinic, $entityType, $entityId); + $counts['packages'] = $this->packagesAndCourses($entityType, $entityId, $services, $patients); + $counts['aftercare'] = $this->cancellationAndWaitlist($entityType, $entityId, $services, $patients, $doctor); + + return $counts; + } + + // ── تسک ۰۴: کاتالوگ نسل دوم ───────────────────────────────────────────── + + /** + * دسته‌بندی + گروهِ انتخاب با بازهٔ مجاز + ناسازگاری + override شعبه. + * + * بدون این‌ها «انتخاب آیتم» فقط یک لیست است و قواعد بند ۵ مستند (حتماً یکی، حداکثر + * سه‌تا، این با آن جمع نمی‌شود) هیچ‌جا دیده نمی‌شوند. + */ + private function catalog(string $entityType, int $entityId, array $services, DoctorAddress $address): int + { + $root = new CatalogCategory($entityType, $entityId, 'خدمات لیزر'); + $child = new CatalogCategory($entityType, $entityId, 'لیزر موهای زائد', $root); + $this->em()->persist($root); + $this->em()->persist($child); + + foreach (array_slice($services, 0, 2) as $service) { + $service->setCatalogCategory($child); + } + + // «حداقل یکی، حداکثر دوتا» — گروهی که بازه‌اش را خودِ دیتا نشان می‌دهد. + $group = new ItemGroup($services[0], 'ناحیهٔ درمان'); + $group->setSelectRange(1, 2); + $this->em()->persist($group); + + foreach ($services as $i => $service) { + $this->em()->persist(new ItemGroupMember($group, $service, $i)); + } + + // دو سرویس که با هم جمع نمی‌شوند — همان مثال «بیکینی با فول‌بادی». + if (count($services) >= 2) { + $this->em()->persist(new ServiceItemRelation($services[0], $services[1], ServiceItemRelation::TYPE_INCOMPATIBLE)); + } + + // قیمت و مدت این سرویس در این شعبه فرق دارد — تا override واقعاً تست شود. + $override = new ServiceBranchOverride($services[1] ?? $services[0], $address); + $override->setPriceRials((int) round(($services[1] ?? $services[0])->getPriceRials() * 1.2)); + $this->em()->persist($override); + + $this->em()->flush(); + + return 5; + } + + // ── تسک ۰۲ و ۰۳: مهارت، استخر، استثنای تقویم ──────────────────────────── + + private function skillsPoolsAndExceptions(string $entityType, int $entityId, DoctorAddress $address, array $devices, ResourceType $deviceType): int + { + $skill = new Skill($entityType, $entityId, 'کار با لیزر'); + $this->em()->persist($skill); + $this->em()->flush(); + + foreach ($devices as $i => $device) { + $this->em()->persist(new ResourceSkill($device, $skill, min(5, 3 + $i))); + } + + // استخر: «هر کدام از این دستگاه‌ها» — انتخاب با اولویت، نه اسمِ ثابت. + $pool = new ResourcePool($address, $deviceType, 'استخر لیزرها'); + $this->em()->persist($pool); + foreach ($devices as $i => $device) { + $this->em()->persist(new ResourcePoolMember($pool, $device, $i)); + } + + // یک دستگاه هفتهٔ بعد برای سرویس دوره‌ای می‌خوابد؛ موتور باید همان بازه را رد کند. + if ($devices !== []) { + $from = strtotime('+7 days 10:00'); + $ex = new ResourceException($devices[0], ResourceException::TYPE_MAINTENANCE, $from, $from + 4 * 3600); + $ex->setReason('سرویس دوره‌ای دستگاه'); + $this->em()->persist($ex); + } + + $this->em()->flush(); + + return 1 + count($devices) * 2 + 1; + } + + // ── تسک ۰۵: برنامهٔ چندبخشی ───────────────────────────────────────────── + + /** + * سه بخش با اشغال متفاوت — دقیقاً جدول بند ۷ مستند: + * بی‌حسی (اتاق و اپراتور اشغال) → انتظار (اتاق اشغال، اپراتور آزاد) → لیزر (همه اشغال). + * + * بخش انتظار `patient_present` دارد ولی `passive` است: بیمار هست، اپراتور نه. اگر + * همه‌ی بخش‌ها `exclusive` باشند، فرق این مدل با یک بازهٔ پیوسته صفر است. + */ + private function multiSegmentPlan(ServiceItem $service, ResourceType $deviceType, string $entityType, int $entityId, DoctorAddress $address): int + { + $roomType = new ResourceType($entityType, $entityId, 'room_plan', 'اتاق برنامه‌دار'); + $this->em()->persist($roomType); + $this->em()->flush(); + + $room = new ClinicResource($address, $roomType, 'اتاق لیزر ۱'); + $room->setActive(true); + $this->em()->persist($room); + $this->em()->flush(); + + foreach ([0, 1, 2, 3, 4] as $day) { + $this->em()->persist(new \App\Resource\Entity\ResourceCalendar($room, $day, 8 * 60, 21 * 60)); + } + + $plan = [ + ['بی‌حسی موضعی', 5, [[$roomType, SegmentRequirement::OCCUPANCY_EXCLUSIVE]], true], + ['انتظار اثر کرم', 30, [[$roomType, SegmentRequirement::OCCUPANCY_PASSIVE]], true], + ['لیزر', 20, [[$roomType, SegmentRequirement::OCCUPANCY_EXCLUSIVE], [$deviceType, SegmentRequirement::OCCUPANCY_EXCLUSIVE]], true], + ]; + + foreach ($plan as $sequence => [$name, $minutes, $requirements, $present]) { + $segment = new SegmentTemplate($service, $sequence + 1, $name); + $segment->setDuration(SegmentTemplate::DURATION_FIXED, $minutes)->setPatientPresent($present); + $this->em()->persist($segment); + + foreach ($requirements as [$type, $occupancy]) { + $requirement = new SegmentRequirement($segment, $type, 1); + $requirement->setOccupancy($occupancy); + $this->em()->persist($requirement); + + // مجموعهٔ نیازمندی‌های بخش سمتِ مالک رابطه نیست، پس persist کردنِ + // نیازمندی آن را پر نمی‌کند. بدون این خط، `AppointmentPlanBuilder` + // در همین پروسه بخشی می‌بیند که هیچ منبعی نمی‌خواهد — و رزروِ seed + // اتاق را اشغال نمی‌کند، در حالی که همان سرویس از راه API درست است. + $segment->getRequirements()->add($requirement); + } + } + + $this->em()->flush(); + + return count($plan); + } + + // ── تسک ۰۸: لیست قیمت ─────────────────────────────────────────────────── + + private function priceList(string $entityType, int $entityId, array $services): int + { + $list = new PriceList($entityType, $entityId, 'تعرفهٔ نیم‌سال دوم', strtotime('-30 days'), strtotime('+180 days')); + $list->setActive(true); + $this->em()->persist($list); + $this->em()->flush(); + + foreach ($services as $service) { + // قیمت لیست عمداً با قیمت پایهٔ سرویس فرق دارد: اگر یکی بودند، معلوم نمی‌شد + // snapshot از کدام منبع خوانده است. + $this->em()->persist(new PriceListItem($list, $service, (int) round($service->getPriceRials() * 0.9))); + } + $this->em()->flush(); + + return count($services); + } + + // ── تسک ۰۹: سیاست‌ها، یکی از هر دسته ──────────────────────────────────── + + private function policies(string $entityType, int $entityId, array $services): int + { + $service = $services[0]; + + $rows = [ + [Policy::CATEGORY_SELECTION, 'حداکثر سه ناحیه در یک نوبت', + [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]], + [['type' => 'forbid']]], + + [Policy::CATEGORY_ELIGIBILITY, 'زیر ۱۸ سال بدون رضایت والدین ممنوع', + [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]], + [['type' => 'require_flag', 'value' => 'parental_consent']]], + + [Policy::CATEGORY_RESOURCE, 'لیزر بدنِ کامل اپراتور ارشد می‌خواهد', + [['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]], + [['type' => 'require_resource', 'value' => 'laser']]], + + [Policy::CATEGORY_TIMING, 'سبد بزرگ ۱۵ دقیقه وقت بیشتر می‌گیرد', + [['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 3]], + [['type' => 'add_duration_minutes', 'value' => 15]]], + + [Policy::CATEGORY_SPACING, 'حداقل ۲۸ روز فاصله بین دو جلسهٔ لیزر', + [['field' => 'service_uuid', 'operator' => 'equals', 'value' => $service->getUuid()]], + [['type' => 'min_days_between', 'value' => 28]]], + + [Policy::CATEGORY_PRICING, 'تخفیف ۱۰٪ برای سبد بالای ۲۰ میلیون ریال', + [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 20_000_000]], + [['type' => 'discount_percent', 'value' => 10]]], + ]; + + foreach ($rows as $i => [$category, $name, $conditions, $effects]) { + $policy = new Policy($entityType, $entityId, $category, $name); + $policy->setCondition(['match' => 'all', 'conditions' => $conditions]) + ->setEffects($effects) + ->setPriority(10 * ($i + 1)) + ->setActive(true); + $this->em()->persist($policy); + } + + $this->em()->flush(); + + return count($rows); + } + + // ── تسک ۰۶ و ۰۷: رزرو واقعی روی تقویم منابع ───────────────────────────── + + /** + * دو نوبت از مسیر کامل: ساخت برنامه → جستجوی آزاد → نگه‌داشتن → ثبت نهایی. + * + * اگر جستجو چیزی برنگرداند (مثلاً منابع لازم آزاد نیستند) بی‌صدا رد می‌شود: seed + * نباید به‌خاطر نبودِ ظرفیت بشکند، ولی دروغ هم نباید بسازد. + */ + private function realBookings( + ServiceItem $service, + DoctorAddress $address, + array $patients, + \App\Doctor\Entity\Doctor $doctor, + ?\App\Clinic\Entity\Clinic $clinic, + string $entityType, + int $entityId, + ): int { + // آیتم انتخابی خالی می‌ماند، دقیقاً مثل مسیر واقعی: پاس‌دادن خودِ سرویس به‌عنوان + // آیتمِ اضافه، برنامهٔ دیگری می‌سازد و نیازمندی اتاق از آن می‌افتد. + $plan = $this->planner->build($service, [], $address); + + $booked = 0; + foreach (array_slice($patients, 0, 2) as $offset => $patient) { + $from = strtotime(sprintf('+%d days 08:00', 3 + $offset)); + $options = $this->availability->search($plan, $address, $from, $from + 86_400); + + if ($options === []) { + continue; + } + + $option = $options[0]; + + // `assignment` یک SlotAssignment است و HoldService آرایهٔ نقش => منابع می‌خواهد. + $hold = $this->holds->hold($patient, $plan, $option->assignment->byRole, $option->start, $entityType, $entityId); + + $appointment = new Appointment($doctor, $patient, $option->start, $option->end); + $appointment->setClinic($clinic); + $appointment->setAddressId($address->getId()); + $appointment->assignTenant(\App\Shared\Context\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->replaceServiceItems([$service]); + $appointment->setServiceDuration($plan->totalMinutes, 0); + $appointment->setVisitPriceRials($service->getPriceRials()); + $this->em()->persist($appointment); + $this->em()->flush(); + + $this->booking->confirm($hold, $appointment); + + // قیمتِ لحظهٔ رزرو ثبت می‌شود، نه ارجاع به تعرفه: تعرفه فردا عوض می‌شود و + // صورتحساب این نوبت نباید با آن تکان بخورد. + $this->snapshots->record($appointment, new PriceQuote( + baseRials: $service->getPriceRials(), + itemsRials: $service->getPriceRials(), + discountRials: 0, + insuranceBaseRials: 0, + insuranceSupplementaryRials: 0, + taxRials: 0, + finalRials: $service->getPriceRials(), + depositRials: (int) round($service->getPriceRials() * 0.2), + )); + + $booked++; + } + + return $booked; + } + + // ── تسک ۱۱ و ۱۲: پکیج، دفتر اعتبار، دورهٔ درمان ───────────────────────── + + private function packagesAndCourses(string $entityType, int $entityId, array $services, array $patients): int + { + $service = $services[0]; + + $package = new Package($entityType, $entityId, 'پکیج ۶ جلسه لیزر', 6); + $package->setPriceRials(60_000_000)->setValidityDays(365)->setActive(true); + $this->em()->persist($package); + $this->em()->persist(new PackageService($package, $service)); + $this->em()->flush(); + + $records = $this->em()->getRepository(PatientRecord::class) + ->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 3); + + $made = 0; + foreach ($records as $i => $record) { + if ($i >= 2) { + break; + } + + $patientPackage = new PatientPackage($package, $record); + $this->em()->persist($patientPackage); + $this->em()->flush(); + + // یک جلسه مصرف‌شده تا دفتر خالی نباشد؛ دفتر append-only است، پس مصرف هم + // یک ردیف است نه کم‌کردن یک عدد. + $this->credits->record($patientPackage, SessionCreditLedger::KIND_CONSUME, -1, null, $service, 'مصرف جلسهٔ اول'); + $made++; + } + + // دورهٔ درمان: پروتکل با پارامتر هر جلسه (انرژی لیزر بالا می‌رود) و یک دورهٔ فعال. + $protocol = new CourseProtocol($service, 6, 21, 28, 45); + $this->em()->persist($protocol); + foreach (range(1, 6) as $n) { + $this->em()->persist(new CourseProtocolStep($protocol, $n, ['energy' => 10 + $n * 2, 'spot_size' => 18])); + } + $this->em()->flush(); + + if ($records !== []) { + $course = new TreatmentCourse($records[0], $protocol); + $this->em()->persist($course); + + // جلسه‌های دوره از روی گام‌های پروتکل ساخته می‌شوند. بدون این ردیف‌ها دوره + // «۶ جلسه‌ای» است ولی هیچ جلسه‌ای ندارد، و پیشنهاد جلسهٔ بعد می‌گوید همه‌چیز + // برنامه‌ریزی شده — یعنی دقیقاً برعکس واقعیت. + foreach ($protocol->getSteps() as $step) { + $this->em()->persist(new \App\Course\Entity\CourseSession( + $course, + $step->getSessionNumber(), + $step->getParams(), + )); + } + + $this->em()->flush(); + $made++; + } + + return $made; + } + + // ── تسک ۱۳: لغو، عدم حضور، لیست انتظار ────────────────────────────────── + + private function cancellationAndWaitlist(string $entityType, int $entityId, array $services, array $patients, \App\Doctor\Entity\Doctor $doctor): int + { + // سیاست عمومیِ محیط + یک سیاست سخت‌گیرانه‌تر روی سرویس گران — تا انتخابِ + // «اختصاصی‌ترین سیاست» چیزی برای انتخاب داشته باشد. + $general = new CancellationPolicy($entityType, $entityId); + $general->setFreeWindowHours(24)->setPenalty(CancellationPolicy::MODE_PERCENT, 30) + ->setDepositRefundable(true)->setCreditRefundable(true)->setNoShowThreshold(3)->setActive(true); + $this->em()->persist($general); + + $strict = new CancellationPolicy($entityType, $entityId, $services[0]); + $strict->setFreeWindowHours(48)->setPenalty(CancellationPolicy::MODE_PERCENT, 50) + ->setDepositRefundable(false)->setCreditRefundable(false)->setNoShowThreshold(2)->setActive(true); + $this->em()->persist($strict); + $this->em()->flush(); + + $records = $this->em()->getRepository(PatientRecord::class) + ->findBy(['entityType' => $entityType, 'entityId' => $entityId], null, 4); + + // عدم حضورِ ثبت‌شده روی نوبت‌هایی که وضعیتشان no_show است — بدون این ردیف، + // شمارندهٔ عدم حضور بیمار همیشه صفر می‌ماند و آستانهٔ سیاست هرگز فعال نمی‌شود. + $noShows = $this->em()->getRepository(Appointment::class) + ->findBy(['doctor' => $doctor, 'status' => Appointment::STATUS_NO_SHOW], null, 2); + + foreach ($noShows as $appointment) { + $record = $this->recordFor($records, $appointment->getUser()); + if ($record !== null) { + $this->em()->persist(new NoShowRecord($record, $appointment)); + } + } + + $made = 2 + count($noShows); + + // لیست انتظار: دو نفر منتظرِ بازهٔ هفتهٔ آینده، یکی با ترجیح صبح. + foreach (array_slice($records, 0, 2) as $i => $record) { + $entry = new WaitlistEntry( + $record, + $services[min($i, count($services) - 1)], + strtotime('+2 days 00:00'), + strtotime('+9 days 00:00'), + $this->em()->getRepository(\App\Doctor\Entity\DoctorAddress::class)->findOneBy([])?->getId(), + ); + $entry->setPriority(10 - $i); + if ($i === 0) { + $entry->setPreferredDayParts(['morning']); + } + $this->em()->persist($entry); + $made++; + } + + $this->em()->flush(); + + return $made; + } + + /** @param PatientRecord[] $records */ + private function recordFor(array $records, User $user): ?PatientRecord + { + foreach ($records as $record) { + if ($record->getUser()->getId() === $user->getId()) { + return $record; + } + } + + return $records[0] ?? null; + } +} diff --git a/src/Shared/Command/SeedScenariosCommand.php b/src/Shared/Command/SeedScenariosCommand.php index 6344bf28..ef7f1d8d 100644 --- a/src/Shared/Command/SeedScenariosCommand.php +++ b/src/Shared/Command/SeedScenariosCommand.php @@ -68,6 +68,13 @@ class SeedScenariosCommand extends Command /** موبایل همهٔ حساب‌های این سیدر با این پیشوند شروع می‌شود — مبنای شناسایی. */ private const MOBILE_PREFIX = '0912000'; + /** برچسب فارسی هر حالت نوبت‌دهی — «منبع‌محور» را با «اسلاتی» یکی نشان ندهیم. */ + private const MODE_LABELS = [ + WeeklySchedule::MODE_SLOT => 'اسلاتی', + WeeklySchedule::MODE_SERVICE => 'سرویسی', + WeeklySchedule::MODE_RESOURCE => 'منبع‌محور', + ]; + /** شهر پیش‌فرض سناریوها؛ دامنهٔ محلیِ سایت عمومی هم همین است. */ private const CITY_ID = 123; // یاسوج → yasuj-nobat.ir @@ -82,9 +89,13 @@ class SeedScenariosCommand extends Command /** @var array نقش، موبایل، توضیح */ private array $report = []; + /** @var array> سناریو => آنچه موتور نوبت‌دهی ساخت */ + private array $engineCounts = []; + public function __construct( private readonly ManagerRegistry $registry, private readonly TenantInsuranceService $insurances, + private readonly BookingEngineSeeder $engine, private readonly string $environment, // hasherِ کانتینر، نه factory دستی: الگوریتم از security.yaml می‌آید و // hashی که خودمان بسازیم ممکن است با چیزی که لاگین می‌سنجد یکی نباشد. @@ -160,6 +171,13 @@ class SeedScenariosCommand extends Command $io->success('سه سناریو ساخته شد. پسورد همهٔ حساب‌ها: ' . self::PASSWORD); $io->table(['نقش', 'موبایل', 'توضیح'], $this->report); + $io->section('موتور نوبت‌دهی (تسک‌های docs/new_feture)'); + $rows = []; + foreach ($this->engineCounts as $scenario => $counts) { + $rows[] = array_merge(['سناریو ' . $scenario], array_values($counts)); + } + $io->table(['سناریو', 'کاتالوگ', 'منابع', 'بخش‌ها', 'قیمت', 'سیاست', 'رزرو واقعی', 'پکیج/دوره', 'لغو/انتظار'], $rows); + return Command::SUCCESS; } @@ -292,8 +310,17 @@ class SeedScenariosCommand extends Command $this->em()->persist(new DoctorSecretary($doctor, $secretary)); $this->report[] = ['۱ · منشی', self::MOBILE_PREFIX . '109', 'منشی دکتر مرادی']; + // پزشک مستقل هم دستگاه دارد — بدون منبع، برنامهٔ چندبخشی چیزی برای تخصیص ندارد. + $laserType = $this->resourceType('doctor', $doctor->getId(), 'laser', 'دستگاه لیزر'); + $lasers = [$this->resource($address, $laserType, 'لیزر دایود مطب', setup: 5, cleanup: 5)]; + $this->resourceHours($lasers[0], [0, 1, 2, 3, 4], 9 * 60, 17 * 60); + $patients = $this->patients('doctor', $doctor->getId(), $doctor->getId(), '11', 5); $this->appointmentsFor($doctor, null, $address, $patients, $services, serviceMode: true, openHour: 9); + + $this->engineCounts['۱'] = $this->engine->seed( + 'doctor', $doctor->getId(), $address, $services, $lasers, $patients, $laserType, $doctor, null, + ); } // ── سناریوی ۲: پزشکی که مالک کلینیک است ───────────────────────────────── @@ -328,7 +355,7 @@ class SeedScenariosCommand extends Command $d = $this->doctor($u, $name, $gender, $code); $clinic->getDoctors()->add($d); $doctors[] = [$d, $mode]; - $this->report[] = ['۲ · پزشک کلینیک — ' . ($mode === WeeklySchedule::MODE_SERVICE ? 'سرویسی' : 'اسلاتی'), $mobile, $name]; + $this->report[] = ['۲ · پزشک کلینیک — ' . self::MODE_LABELS[$mode], $mobile, $name]; } foreach ($doctors as [$d, $mode]) { @@ -372,6 +399,10 @@ class SeedScenariosCommand extends Command foreach ($doctors as [$d, $mode]) { $this->appointmentsFor($d, $clinic, $address, $patients, $services, serviceMode: $mode === WeeklySchedule::MODE_SERVICE, openHour: 16); } + + $this->engineCounts['۲'] = $this->engine->seed( + 'clinic', $clinic->getId(), $address, $services, $lasers, $patients, $laserType, $owner, $clinic, + ); } // ── سناریوی ۳: کلینیک مستقل با مالک غیرپزشک ───────────────────────────── @@ -406,7 +437,7 @@ class SeedScenariosCommand extends Command $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]; + $this->report[] = ['۳ · پزشک درمانگاه — ' . self::MODE_LABELS[$mode], $mobile, $name]; } $laserType = $this->resourceType('clinic', $clinic->getId(), 'laser', 'دستگاه لیزر'); @@ -444,6 +475,10 @@ class SeedScenariosCommand extends Command foreach ($doctors as [$d, $mode]) { $this->appointmentsFor($d, $clinic, $address, $patients, $services, serviceMode: $mode === WeeklySchedule::MODE_SERVICE, openHour: 8); } + + $this->engineCounts['۳'] = $this->engine->seed( + 'clinic', $clinic->getId(), $address, $services, $lasers, $patients, $laserType, $doctors[1][0], $clinic, + ); } // ── سازنده‌های مشترک ─────────────────────────────────────────────────────