From 0a2ba88808a8b6963b5f81cff52230656775c08f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 21:48:45 +0330 Subject: [PATCH] Record which resource an appointment was booked for, and freeze its numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An appointment could say which services it was for but not which resource performed them, so a booking on laser #2 was indistinguishable from one on laser #1. Both columns are nullable: the appointments that already exist have no resource and the migration must not break them. resource_id is not a duplicate of resource_occupancy. Occupancy records what was held and when — including rooms and devices held for a single segment. This column records what the appointment is *for*, which is what the panel lists and what the patient chose. The option is kept separately from service_item because duration and price resolve from the resource+service+option triple; without knowing the option, the stored number cannot be explained later. Tests: the resource and option survive a round-trip, stored minutes come from the resolver rather than the service default (15 where the service says 30), raising the tariff afterwards leaves the earlier snapshot at 8M, and an appointment with no resource still serialises with nulls instead of failing. Suite 1290 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 --- .../task-15-resource-first-model/checklist.md | 14 +- migrations/Version20260801181433.php | 39 ++++ src/Appointment/Entity/Appointment.php | 51 +++++ .../Appointment/ResourceOnAppointmentTest.php | 198 ++++++++++++++++++ 4 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 migrations/Version20260801181433.php create mode 100644 tests/Appointment/ResourceOnAppointmentTest.php 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']); + } +}