Record which resource an appointment was booked for, and freeze its numbers

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 21:48:45 +03:30
co-authored by Claude Opus 5
parent 6d7c54508c
commit 0a2ba88808
4 changed files with 295 additions and 7 deletions
@@ -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ها
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260801181433 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
+51
View File
@@ -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<int,\App\ClinicService\Entity\ServiceItem> */
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,
@@ -0,0 +1,198 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Service\ResourceServiceResolver;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Entity\PriceSnapshot;
use App\Pricing\Service\PriceSnapshotService;
use App\Pricing\ValueObject\PriceQuote;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* نوبت باید بگوید **برای کدام منبع** گرفته شده و با کدام گزینه، و عددهایش را
* به‌صورت snapshot نگه دارد.
*
* بدون این، نوبتِ «دستگاه لیزر ۲» از نوبتِ «دستگاه لیزر ۱» قابل تشخیص نیست و تغییر
* فردای تعرفه، صورتحساب دیروز را عوض می‌کند.
*/
class ResourceOnAppointmentTest extends ApiTestCase
{
private Clinic $clinic;
private Doctor $doctor;
private DoctorAddress $address;
private ServiceSection $section;
protected function setUp(): void
{
parent::setUp();
$owner = $this->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']);
}
}