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:
@@ -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` رد
|
||||
میشوند. برای همین اشغال منبع و رویداد دامنه واقعیاند: گزارش بهرهوری عدد نشان میدهد و
|
||||
تقویم دستگاه واقعاً پر است.
|
||||
|
||||
## چه چیزی با این داده قابل تست است
|
||||
|
||||
| قابلیت | کجا |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
// ── سازندههای مشترک ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user