Files
clinicpro/tests/Appointment/ResourceOnAppointmentTest.php
hamedandClaude Opus 5 0a2ba88808 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>
2026-08-01 21:48:45 +03:30

199 lines
7.9 KiB
PHP

<?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']);
}
}