Files
clinicpro/tests/Appointment/ResourceBookingIsIndependentOfDoctorTest.php
T
hamedandClaude Opus 5 9a95bc59d4 fix(appointment): make resource bookings independent of the doctor's calendar
Booking a device is not booking its doctor: the operator runs it and the doctor
only supervises. But bookAtomically locked the doctor row and isSlotTaken checked
overlap against the doctor alone, ignoring which resource was chosen, so a clinic
whose devices share one supervisor could not run two of them at once. Every
tenant in the database is in that position — clinic 2's six resources all point
at doctor 6.

Resource bookings now skip the doctor lock and carry no active_slot_key; their
guarantee comes from resource_occupancy, which understands capacity and seats.
Both direct paths write occupancy rows the way the hold engine already did, so
ResourceBookingSlotService stops being the only thing holding two sources of
truth together, and cancelling releases the seat.

Occupancy is bucketed in five-minute slices, which is coarser than a booking
time: a booking ending 12:35:04 spilled four seconds into the 12:35 bucket and
collided with the next one starting at that same second, despite zero real
overlap. This surfaced on real rows 76 and 77 during backfill. Resource bookings
now snap both ends of their window down to the bucket grid — schedule-driven
slots are already aligned, so only manually entered times move.

The seat is claimed after persist because it needs the appointment id; losing
the race removes the appointment rather than leaving a booking with no device
behind it.

app:appointment:backfill-resource-occupancy gives existing resource-backed
appointments their missing occupancy and clears the doctor keys that no longer
mean anything. It reports conflicts between two old bookings instead of picking
a loser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:57:00 +03:30

236 lines
10 KiB
PHP

<?php
namespace App\Tests\Appointment;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Entity\Appointment;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* رزرو منبع از تقویم پزشک مستقل است.
*
* پیش از این `bookAtomically` روی پزشک قفل می‌گرفت و `isSlotTaken` تداخل را فقط روی
* پزشک می‌سنجید؛ کلینیکی که چند دستگاه زیر نظر یک پزشک داشت — که در دیتابیس واقعی
* حالتِ همهٔ محیط‌ها بود — نمی‌توانست دو دستگاهش را هم‌ساعت رزرو کند.
*/
class ResourceBookingIsIndependentOfDoctorTest extends ApiTestCase
{
/** @return array{0: Clinic, 1: Doctor, 2: DoctorAddress} */
private function clinic(): array
{
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
$clinic->setName('کلینیک چنددستگاهی');
$this->em->persist($clinic);
$this->em->flush();
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return [$clinic, $doctor, $address];
}
private function device(Clinic $clinic, DoctorAddress $address, Doctor $supervisor, string $name, int $capacity = 1): ClinicResource
{
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'دستگاه لیزر');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, $name);
$resource->setSupervisor($supervisor);
$resource->setCapacity($capacity);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
/** @return array{0: array, 1: int} */
private function book(ClinicResource $resource, int $start): array
{
return [$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), [
'resource_uuid' => $resource->getUuid(),
'slot_start' => $start,
'slot_end' => $start + 1800,
'for_self' => true,
'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'patient_gender' => 'female',
]), $start];
}
private function futureSlot(): int
{
return time() + 86_400 + random_int(1, 5_000) * 60;
}
/** ✅ همان باگی که در دیتابیس واقعی فعال بود. */
public function testTwoDevicesUnderOneSupervisorCanBeBookedAtTheSameHour(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$first = $this->device($clinic, $address, $doctor, 'لیزر CO2');
$second = $this->device($clinic, $address, $doctor, 'لیزر NdYAG');
$slot = $this->futureSlot();
$this->book($first, $slot);
self::assertSame(201, $this->responseCode());
$res = $this->book($second, $slot)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
/** ❌ همان دستگاه، همان ساعت، دو بار — باید رد شود. */
public function testTheSameDeviceCannotBeDoubleBooked(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر تک');
$slot = $this->futureSlot();
$this->book($device, $slot);
self::assertSame(201, $this->responseCode());
$res = $this->book($device, $slot)[0];
self::assertSame(409, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
/** ⚠️ اتاق سه‌نفره باید سه رزرو بپذیرد و چهارمی را رد کند. */
public function testARoomWithCapacityThreeTakesThreeBookings(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$room = $this->device($clinic, $address, $doctor, 'اتاق سه‌نفره', capacity: 3);
$slot = $this->futureSlot();
for ($i = 0; $i < 3; $i++) {
$res = $this->book($room, $slot)[0];
self::assertSame(201, $this->responseCode(), 'رزرو ' . ($i + 1) . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE));
}
$this->book($room, $slot);
self::assertSame(409, $this->responseCode());
}
/** رزرو منبع‌دار کلید پزشک نمی‌گیرد؛ تضمینش جای دیگری است. */
public function testAResourceBookingCarriesNoDoctorSlotKeyButDoesOccupy(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر کلیددار');
$slot = $this->futureSlot();
$res = $this->book($device, $slot)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$appointment = $this->em->getRepository(Appointment::class)
->findOneBy(['uuid' => $res['data']['data']['uuid']]);
self::assertNotNull($appointment->getResource());
$occupancies = $this->em->getRepository(ResourceOccupancy::class)
->findBy(['appointmentId' => $appointment->getId()]);
self::assertCount(1, $occupancies);
self::assertSame(ResourceOccupancy::STATUS_BOOKED, $occupancies[0]->getStatus());
}
/**
* دو نوبتِ پشت‌سرهم روی یک دستگاه — هیچ هم‌پوشانی واقعی ندارند.
*
* سطل‌های اشغال پنج‌دقیقه‌ای‌اند، پس نوبتی که ۱۲:۳۵:۰۴ تمام می‌شود چهار ثانیه وارد
* سطلِ ۱۲:۳۵ می‌شد و نوبت بعدی که از همان ثانیه شروع می‌کرد سطل مشترک پیدا
* می‌کرد. این دقیقاً همان چیزی بود که در دیتابیس واقعی روی نوبت‌های ۷۶ و ۷۷ افتاد.
*/
public function testBackToBackBookingsOnOneDeviceDoNotCollide(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر پشت‌سرهم');
// عمداً روی مرز سطل ننشسته — همان «ثبت خارج از برنامه (ورود دستی ساعت)».
$first = $this->futureSlot() + 4;
$this->book($device, $first);
self::assertSame(201, $this->responseCode());
$res = $this->book($device, $first + 1800)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
/** ساعتِ دستی روی مرز سطل می‌نشیند تا اشغال دقیق بماند. */
public function testAManualTimeIsAlignedToTheBucketGrid(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر ترازشونده');
$slot = $this->futureSlot() + 4;
$res = $this->book($device, $slot)[0];
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$appointment = $this->em->getRepository(Appointment::class)
->findOneBy(['uuid' => $res['data']['data']['uuid']]);
self::assertSame(0, $appointment->getSlotStart() % 300);
self::assertSame(0, $appointment->getSlotEnd() % 300);
}
/** رزرو بدون منبع همچنان با کلید و قفل پزشک محافظت می‌شود. */
public function testBookingWithoutAResourceIsStillGuardedByTheDoctorKey(): void
{
[$clinic, $doctor] = $this->clinic();
$slot = $this->futureSlot();
$payload = static fn (): array => [
'for_self' => true,
'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'patient_gender' => 'female',
];
$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $payload() + [
'doctor_uuid' => $doctor->getUuid(),
'slot_start' => $slot,
'slot_end' => $slot + 1800,
]);
self::assertSame(201, $this->responseCode());
$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $payload() + [
'doctor_uuid' => $doctor->getUuid(),
'slot_start' => $slot,
'slot_end' => $slot + 1800,
]);
self::assertSame(409, $this->responseCode());
}
/** لغو باید صندلی را پس بدهد، وگرنه دستگاه برای همیشه در آن ساعت پر می‌ماند. */
public function testCancellingReleasesTheSeat(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->device($clinic, $address, $doctor, 'لیزر لغوشونده');
$slot = $this->futureSlot();
$res = $this->book($device, $slot)[0];
$uuid = $res['data']['data']['uuid'];
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
$this->authJson('PATCH', '/api/v1/appointment/' . $uuid . '/status', $clinic->getUser(), [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$this->em->clear();
$occupancies = $this->em->getRepository(ResourceOccupancy::class)
->findBy(['appointmentId' => $appointment->getId()]);
self::assertSame(ResourceOccupancy::STATUS_RELEASED, $occupancies[0]->getStatus());
// و همان ساعت دوباره قابل رزرو است.
$this->book($device, $slot);
self::assertSame(201, $this->responseCode());
}
}