feat(seed): make the seeded environments exercise the new booking engine

The first version of the seeder only filled the old skeleton: doctors, clinics,
services, appointments written straight to the table. None of the sixteen tasks
under docs/new_feture had any data, so nothing they built could be tried.

BookingEngineSeeder now seeds, per environment:

- catalog v2: a category tree, an item group with a 1..2 selection range, an
  incompatible pair, and a per-branch price override
- resources: a skill with levels, a resource pool with priorities, and a
  maintenance window next week
- a three-segment plan on the flagship service — numbing (room exclusive), wait
  (room passive, nobody else held), laser (room + device) — which is the whole
  point of the plan model and cannot be seen with single-segment services
- an active price list, and a price snapshot per booked appointment
- six policies, one per category, each with a condition and an effect
- a package with a consumed session in the ledger, and a treatment protocol with
  per-session parameters plus an active course carrying its six sessions
- a general and a per-service cancellation policy, no-show records, waitlist
  entries

Two appointments per environment are booked through the real path —
AppointmentPlanBuilder, AvailabilityEngine, HoldService, BookingService — so
segments, resource occupancy and the domain-event outbox are populated by the
code that will run in production rather than by INSERTs.

Two defects in the seeding surfaced and are fixed here:

- SegmentRequirement is the owning side, so persisting one leaves the template's
  in-memory collection empty. The plan built later in the same process saw
  segments that needed nothing, and the bookings occupied the device but never
  the room — while the same service was correct over HTTP, where the entity is
  read fresh. The collection is now kept in step.
- passing the service as its own selected item produced a different plan than
  the booking flow builds.

A course with no sessions also reported "everything is scheduled"; sessions are
now created from the protocol steps.

Verified over HTTP: segments come back as 5/30/20, utilization reports 110
minutes on the room and 90 on the laser, the course suggests session 1 with
three slots, and the six policies list one per category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 19:28:26 +03:30
co-authored by Claude Opus 5
parent 369f3ae710
commit fc6b865c15
3 changed files with 565 additions and 2 deletions
+25
View File
@@ -82,6 +82,31 @@ POST /api/v1/auth/switch-context {"db_uuid": "<clinic_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` رد
می‌شوند. برای همین اشغال منبع و رویداد دامنه واقعی‌اند: گزارش بهره‌وری عدد نشان می‌دهد و
تقویم دستگاه واقعاً پر است.
## چه چیزی با این داده قابل تست است
| قابلیت | کجا |
+503
View File
@@ -0,0 +1,503 @@
<?php
namespace App\Shared\Command;
use App\Appointment\Availability\Service\AvailabilityEngine;
use App\Appointment\Booking\Service\BookingService;
use App\Appointment\Booking\Service\HoldService;
use App\Appointment\Entity\Appointment;
use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Cancellation\Entity\CancellationPolicy;
use App\Cancellation\Entity\NoShowRecord;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use App\Course\Entity\CourseProtocol;
use App\Course\Entity\CourseProtocolStep;
use App\Course\Entity\TreatmentCourse;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Entity\Package;
use App\Package\Entity\PackageService;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Service\CreditLedgerService;
use App\Patient\Entity\PatientRecord;
use App\Policy\Entity\Policy;
use App\Pricing\Entity\PriceList;
use App\Pricing\Entity\PriceListItem;
use App\Pricing\Service\PriceSnapshotService;
use App\Pricing\ValueObject\PriceQuote;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceException;
use App\Resource\Entity\ResourcePool;
use App\Resource\Entity\ResourcePoolMember;
use App\Resource\Entity\ResourceSkill;
use App\Resource\Entity\ResourceType;
use App\Resource\Entity\Skill;
use App\Waitlist\Entity\WaitlistEntry;
use Doctrine\Persistence\ManagerRegistry;
/**
* دادهٔ سناریو برای موتور نوبت‌دهی جدید — همان چیزی که تسک‌های `docs/new_feture` ساختند.
*
* چرا جداست از {@see SeedScenariosCommand}: آن یکی «چه کسی و کجا» را می‌سازد (کاربر،
* پزشک، کلینیک، آدرس، برنامهٔ هفتگی). این یکی «با چه قوانینی» را می‌سازد: برنامهٔ
* چندبخشی، منبع و مهارت و استخر، لیست قیمت، سیاست، پکیج، دوره، لیست انتظار.
*
* نوبت‌های این کلاس از **مسیر واقعی** رد می‌شوند: `AppointmentPlanBuilder` → جستجوی
* `AvailabilityEngine` → `HoldService::hold()` → `BookingService::confirm()`. نوبتی که با
* `new Appointment(...)` ساخته شود نه بخش دارد نه اشغال منبع، پس تقویم منبع، گزارش
* بهره‌وری و لغو، همه روی داده‌ای کار می‌کنند که هیچ‌وقت وجود نداشته.
*/
final class BookingEngineSeeder
{
public function __construct(
private readonly ManagerRegistry $registry,
private readonly AppointmentPlanBuilder $planner,
private readonly AvailabilityEngine $availability,
private readonly HoldService $holds,
private readonly BookingService $booking,
private readonly PriceSnapshotService $snapshots,
private readonly CreditLedgerService $credits,
) {}
private function em(): \Doctrine\ORM\EntityManagerInterface
{
/** @var \Doctrine\ORM\EntityManagerInterface $manager */
$manager = $this->registry->getManager();
return $manager;
}
/**
* @param ServiceItem[] $services
* @param ClinicResource[] $devices دستگاه‌های همان محیط
* @param User[] $patients
*
* @return array<string, int> شمارش آنچه ساخته شد، برای گزارش دستور
*/
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;
}
}
+37 -2
View File
@@ -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<int, array{0:string,1:string,2:string}> نقش، موبایل، توضیح */
private array $report = [];
/** @var array<string, array<string, int>> سناریو => آنچه موتور نوبت‌دهی ساخت */
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,
);
}
// ── سازنده‌های مشترک ─────────────────────────────────────────────────────