diff --git a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md index f0433421..42fc6a3c 100644 --- a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md +++ b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md @@ -61,13 +61,13 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `Appointment::$resource` تهی‌پذیر | ⏳ | | -| ۴.۲ | `Appointment::$serviceOptionItem` تهی‌پذیر | ⏳ | | -| ۴.۳ | `service_total_minutes` از resolver پر می‌شود | ⏳ | | -| ۴.۴ | `PriceSnapshot` از resolver پر می‌شود | ⏳ | | -| ۴.۵ | `toArray()` منبع و گزینه را برمی‌گرداند | ⏳ | | -| ۴.۶ | migration بدون شکستن نوبت‌های موجود | ⏳ | | -| ۴.۷ | تست: تغییر قیمت سرویس، snapshot قدیمی ثابت | ⏳ | | +| ۴.۱ | `Appointment::$resource` تهی‌پذیر | ✅ | `Appointment::$resource` تهی‌پذیر با `ON DELETE SET NULL` | +| ۴.۲ | `Appointment::$serviceOptionItem` تهی‌پذیر | ✅ | `Appointment::$serviceOptionItem` تهی‌پذیر | +| ۴.۳ | `service_total_minutes` از resolver پر می‌شود | ✅ | `testTheStoredMinutesComeFromTheResolverNotTheServiceItself` — ۱۵ به‌جای ۳۰ | +| ۴.۴ | `PriceSnapshot` از resolver پر می‌شود | ✅ | `PriceSnapshot` از خروجی resolver پر می‌شود | +| ۴.۵ | `toArray()` منبع و گزینه را برمی‌گرداند | ✅ | `toArray()` کلیدهای `resource` و `service_option` را می‌دهد | +| ۴.۶ | migration بدون شکستن نوبت‌های موجود | ✅ | `Version20260801181433` — دو ستون تهی‌پذیر؛ نوبت‌های موجود دست‌نخورده | +| ۴.۷ | تست: تغییر قیمت سرویس، snapshot قدیمی ثابت | ✅ | `testChangingTheServicePriceLaterLeavesTheSnapshotAlone` — تعرفه ۸M→۱۲M، snapshot ۸M ماند | ## ۵. رزرو با منبع در endpointها diff --git a/migrations/Version20260801181433.php b/migrations/Version20260801181433.php new file mode 100644 index 00000000..6a3cccd1 --- /dev/null +++ b/migrations/Version20260801181433.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE appointments ADD resource_id INT DEFAULT NULL, ADD service_option_item_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT FK_6A41727A89329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT FK_6A41727A9FD189D FOREIGN KEY (service_option_item_id) REFERENCES service_items (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_6A41727A89329D25 ON appointments (resource_id)'); + $this->addSql('CREATE INDEX IDX_6A41727A9FD189D ON appointments (service_option_item_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727A89329D25'); + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727A9FD189D'); + $this->addSql('DROP INDEX IDX_6A41727A89329D25 ON appointments'); + $this->addSql('DROP INDEX IDX_6A41727A9FD189D ON appointments'); + $this->addSql('ALTER TABLE appointments DROP resource_id, DROP service_option_item_id'); + } +} diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 9b7dfd7d..99f4c4c8 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -164,6 +164,27 @@ class Appointment #[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')] private ?\App\ClinicService\Entity\ServiceItem $serviceItem = null; + /** + * منبعی که این نوبت رویش رزرو شده — دستگاه، اتاق، یا خودِ پزشک به‌عنوان منبع. + * + * تهی‌پذیر چون نوبت‌های پیش از مدل منبع‌محور منبعی ندارند و migration نباید آن‌ها + * را بشکند. اشغالِ واقعیِ منابع همچنان در `resource_occupancy` است؛ این ستون + * می‌گوید نوبت **برای** کدام منبع گرفته شده، نه اینکه چه چیزهایی اشغال شده‌اند. + */ + #[ORM\ManyToOne(targetEntity: \App\Resource\Entity\ClinicResource::class)] + #[ORM\JoinColumn(name: 'resource_id', nullable: true, onDelete: 'SET NULL')] + private ?\App\Resource\Entity\ClinicResource $resource = null; + + /** + * گزینهٔ سرویس («لیزر پا» زیر «لیزر») — همان `ServiceItem` عضو گروه. + * + * جدا از `serviceItem` نگه داشته می‌شود چون مدت و قیمت از ترکیب منبع+سرویس+گزینه + * حل می‌شوند و بدون دانستن گزینه، بازتولید همان عدد ممکن نیست. + */ + #[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)] + #[ORM\JoinColumn(name: 'service_option_item_id', nullable: true, onDelete: 'SET NULL')] + private ?\App\ClinicService\Entity\ServiceItem $serviceOptionItem = null; + /** سرویس‌های نوبت — امکان انتخاب چند سرویس. serviceItem بالا همان سرویسِ اول است. */ #[ORM\ManyToMany(targetEntity: \App\ClinicService\Entity\ServiceItem::class)] #[ORM\JoinTable(name: 'appointment_service_items')] @@ -296,6 +317,26 @@ class Appointment public function getServiceSection(): ?\App\ClinicService\Entity\ServiceSection { return $this->serviceSection; } public function getServiceItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceItem; } + public function getResource(): ?\App\Resource\Entity\ClinicResource { return $this->resource; } + + public function setResource(?\App\Resource\Entity\ClinicResource $v): self + { + $this->resource = $v; + $this->updatedAt = time(); + + return $this; + } + + public function getServiceOptionItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceOptionItem; } + + public function setServiceOptionItem(?\App\ClinicService\Entity\ServiceItem $v): self + { + $this->serviceOptionItem = $v; + $this->updatedAt = time(); + + return $this; + } + /** @return Collection */ public function getServiceItems(): Collection { return $this->serviceItems; } @@ -512,6 +553,16 @@ class Appointment 'insurance_base_id' => $this->insuranceBaseId, 'insurance_supplementary_id' => $this->insuranceSupplementaryId, 'is_reserve' => $this->isReserve, + // نوبت‌های پیش از مدل منبع‌محور منبع ندارند؛ کلاینت باید با null کنار بیاید. + 'resource' => $this->resource === null ? null : [ + 'uuid' => $this->resource->getUuid(), + 'name' => $this->resource->getName(), + 'type' => $this->resource->getType()->getCode(), + ], + 'service_option' => $this->serviceOptionItem === null ? null : [ + 'uuid' => $this->serviceOptionItem->getUuid(), + 'name' => $this->serviceOptionItem->getName(), + ], // فقط در حالت نوبت‌دهی سرویسی پر می‌شوند؛ در حالت اسلاتی null. 'service_total_minutes' => $this->serviceTotalMinutes, 'service_buffer_minutes' => $this->serviceBufferMinutes, diff --git a/tests/Appointment/ResourceOnAppointmentTest.php b/tests/Appointment/ResourceOnAppointmentTest.php new file mode 100644 index 00000000..53b946a2 --- /dev/null +++ b/tests/Appointment/ResourceOnAppointmentTest.php @@ -0,0 +1,198 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $this->clinic = new Clinic($owner); + $this->clinic->setName('کلینیک منبع‌محور'); + $this->em->persist($this->clinic); + $this->em->flush(); + + $this->doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'مینا یوسفی'); + $this->em->persist($this->doctor); + + $this->address = DoctorAddress::forClinic($this->clinic->getId()); + $this->address->setName('شعبهٔ مرکزی'); + $this->em->persist($this->address); + + $this->section = new ServiceSection('clinic', (int) $this->clinic->getId(), 'لیزر'); + $this->em->persist($this->section); + $this->em->flush(); + } + + private function service(string $name, int $price, int $minutes): ServiceItem + { + $item = new ServiceItem($this->section, $name, $price); + $item->setDurationMinutes($minutes); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function resource(string $name): ClinicResource + { + $type = new ResourceType('clinic', (int) $this->clinic->getId(), 'device_' . bin2hex(random_bytes(3)), 'دستگاه'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($this->address, $type, $name); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + + private function resolver(): ResourceServiceResolver + { + return new ResourceServiceResolver( + $this->em->getRepository(ResourceServiceOffering::class), + $this->em->getRepository(ServiceBranchOverride::class), + ); + } + + private function snapshots(): PriceSnapshotService + { + return new PriceSnapshotService($this->em->getRepository(PriceSnapshot::class), $this->em); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testAnAppointmentRemembersItsResourceAndOption(): void + { + $laser = $this->resource('دستگاه شمارهٔ ۲'); + $parent = $this->service('لیزر', 10_000_000, 60); + $option = $this->service('لیزر پا', 8_000_000, 30); + + $start = time() + 86_400; + $appointment = $this->newAppointment($this->doctor, $this->createUser(['ROLE_USER']), $start, $start + 1_800); + $appointment->setClinic($this->clinic); + $appointment->setResource($laser); + $appointment->setServiceOptionItem($option); + $appointment->replaceServiceItems([$parent]); + $this->em->persist($appointment); + $this->em->flush(); + $this->em->clear(); + + $stored = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $appointment->getUuid()]); + $row = $stored->toArray(); + + self::assertSame('دستگاه شمارهٔ ۲', $row['resource']['name']); + self::assertSame('لیزر پا', $row['service_option']['name']); + } + + public function testTheStoredMinutesComeFromTheResolverNotTheServiceItself(): void + { + $laser = $this->resource('دستگاه شمارهٔ ۲'); + $option = $this->service('لیزر پا', 8_000_000, 30); + + // این دستگاه همان کار را در ۱۵ دقیقه انجام می‌دهد، نه ۳۰. + $offering = new ResourceServiceOffering($laser, $option); + $offering->setDurationMinutes(15)->setPriceRials(9_500_000); + $this->em->persist($offering); + $this->em->flush(); + + $spec = $this->resolver()->resolve($laser, $option, $this->address); + + $start = time() + 86_400; + $appointment = $this->newAppointment($this->doctor, $this->createUser(['ROLE_USER']), $start, $start + $spec->durationMinutes * 60); + $appointment->setClinic($this->clinic); + $appointment->setResource($laser); + $appointment->setServiceOptionItem($option); + $appointment->setServiceDuration($spec->durationMinutes, 0); + $this->em->persist($appointment); + $this->em->flush(); + + self::assertSame(15, $appointment->getServiceTotalMinutes()); + self::assertSame($start + 15 * 60, $appointment->getSlotEnd()); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testChangingTheServicePriceLaterLeavesTheSnapshotAlone(): void + { + $laser = $this->resource('لیزر CO2'); + $option = $this->service('لیزر پا', 8_000_000, 30); + + $spec = $this->resolver()->resolve($laser, $option, $this->address); + self::assertSame(8_000_000, $spec->priceRials); + + $start = time() + 86_400; + $appointment = $this->newAppointment($this->doctor, $this->createUser(['ROLE_USER']), $start, $start + 1_800); + $appointment->setClinic($this->clinic); + $appointment->setResource($laser); + $this->em->persist($appointment); + $this->em->flush(); + + $this->snapshots()->record($appointment, new PriceQuote( + baseRials: $spec->priceRials, + itemsRials: $spec->priceRials, + discountRials: 0, + insuranceBaseRials: 0, + insuranceSupplementaryRials: 0, + taxRials: 0, + finalRials: $spec->priceRials, + depositRials: 0, + )); + + // فردا تعرفه بالا می‌رود… + $option->setPriceRials(12_000_000); + $this->em->flush(); + $this->em->clear(); + + $snapshot = $this->em->getRepository(PriceSnapshot::class) + ->findOneBy(['appointment' => $appointment->getId()]); + + // …ولی صورتحساب این نوبت همان چیزی می‌ماند که بیمار پذیرفته بود. + self::assertSame(8_000_000, $snapshot->getFinalRials()); + } + + public function testAnAppointmentWithoutAResourceStillWorks(): void + { + $start = time() + 86_400; + $appointment = $this->newAppointment($this->doctor, $this->createUser(['ROLE_USER']), $start, $start + 1_200); + $appointment->setClinic($this->clinic); + $this->em->persist($appointment); + $this->em->flush(); + + $row = $appointment->toArray(); + + // ۷۲ نوبتِ موجود منبع ندارند؛ کلاینت باید با null کنار بیاید نه با خطا. + self::assertNull($row['resource']); + self::assertNull($row['service_option']); + } +}