feat: Implement resource booking functionality

- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
hamed
2026-08-03 14:34:23 +03:30
parent 981261ed3a
commit 4f69bc9044
21 changed files with 2045 additions and 514 deletions
@@ -0,0 +1,236 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* ثبت نوبت **برای یک منبع** از پنل: همان `POST /api/v1/my/appointment`، با
* `resource_uuid`.
*
* مدت از زنجیرهٔ حلِ همان منبع می‌آید نه از پیش‌فرضِ سرویس، پزشک از ناظرِ منبع
* استنتاج می‌شود، و تداخل روی خودِ منبع جدا از اسلاتِ پزشک سنجیده می‌شود.
*/
class ResourceAppointmentCreateTest extends ApiTestCase
{
/** @return array{0: User, 1: Doctor, 2: ClinicResource, 3: DoctorAddress} */
private function doctorWithResource(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, 'دکتر منبع');
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
$address = DoctorAddress::forDoctor($doctor);
$address->setName('مطب شخصی');
$this->em->persist($address);
$this->em->flush();
$type = new ResourceType('doctor', (int) $doctor->getId(), 'device', 'دستگاه');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, 'لیزر CO2');
$resource->setSupervisor($doctor);
$this->em->persist($resource);
$this->em->flush();
return [$user, $doctor, $resource, $address];
}
private function service(DoctorAddress $address, string $name, ?int $serviceMinutes): ServiceItem
{
$section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name);
$this->em->persist($section);
$item = new ServiceItem($section, $name, 5_000_000);
$item->setBookable(true);
if ($serviceMinutes !== null) {
$item->setDurationMinutes($serviceMinutes);
}
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function offer(ClinicResource $resource, ServiceItem $item, ?int $minutes): void
{
$offering = new ResourceServiceOffering($resource, $item);
$offering->setDurationMinutes($minutes);
$this->em->persist($offering);
$this->em->flush();
}
/** @param array<string, mixed> $extra */
private function body(ClinicResource $resource, int $start, array $extra = []): array
{
return array_merge([
'resource_uuid' => $resource->getUuid(),
'slot_start' => $start,
'slot_end' => $start + 600,
'duration_from_services' => true,
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
'patient_name' => 'بیمار منبع',
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
], $extra);
}
private function reload(string $uuid): Appointment
{
$this->em->clear();
return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
}
private function tomorrowAt(int $hour): int
{
return (int) strtotime('tomorrow midnight') + $hour * 3600;
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testTheAppointmentLandsOnTheResourceWithTheResourceDuration(): void
{
[$user, $doctor, $resource, $address] = $this->doctorWithResource();
// پیش‌فرض سرویس ۳۰ دقیقه است، ولی این دستگاه ۵۰ دقیقه می‌گیرد.
$service = $this->service($address, 'RF فرکشنال', 30);
$this->offer($resource, $service, 50);
$start = $this->tomorrowAt(10);
$res = $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [
'service_item_uuids' => [$service->getUuid()],
]));
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$appointment = $this->reload($res['data']['uuid']);
self::assertSame($resource->getUuid(), $appointment->getResource()?->getUuid());
self::assertSame($start + 50 * 60, $appointment->getSlotEnd());
// پزشک فرستاده نشد؛ از ناظرِ منبع آمد.
self::assertSame($doctor->getUuid(), $appointment->getDoctor()->getUuid());
}
public function testSeveralServicesAddUpAndAPerRequestDurationWins(): void
{
[$user, , $resource, $address] = $this->doctorWithResource();
$laser = $this->service($address, 'لیزر زیربغل', 20);
$cryo = $this->service($address, 'کرایوتراپی', 25);
$this->offer($resource, $laser, 20);
$this->offer($resource, $cryo, 25);
$start = $this->tomorrowAt(11);
$res = $this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [
'service_item_uuids' => [$laser->getUuid(), $cryo->getUuid()],
'service_durations' => [$laser->getUuid() => 40],
]));
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
self::assertSame($start + 65 * 60, $this->reload($res['data']['uuid'])->getSlotEnd());
}
// ── ⚠️ مرزی ─────────────────────────────────────────────────────────────
/** ظرفیت ۲ یعنی نوبت دوم هنوز جا دارد. */
public function testASecondBookingFitsWhileTheResourceStillHasCapacity(): void
{
[$user, , $resource, $address] = $this->doctorWithResource();
$resource->setCapacity(2);
$this->em->flush();
$service = $this->service($address, 'اکسیژن‌تراپی', 60);
$this->offer($resource, $service, 60);
$start = $this->tomorrowAt(9);
$this->em->persist(new ResourceOccupancy($resource, $start, $start + 3600));
$this->em->flush();
$this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [
'service_item_uuids' => [$service->getUuid()],
]));
self::assertSame(201, $this->responseCode());
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
/** دستگاهی که موتور منبع‌محور گرفته، از پنل دوباره فروخته نمی‌شود. */
public function testATimeAlreadyTakenOnTheResourceIsRefused(): void
{
[$user, , $resource, $address] = $this->doctorWithResource();
$service = $this->service($address, 'مزوتراپی', 60);
$this->offer($resource, $service, 60);
$start = $this->tomorrowAt(14);
$this->em->persist(new ResourceOccupancy($resource, $start, $start + 3600));
$this->em->flush();
$this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $start, [
'service_item_uuids' => [$service->getUuid()],
]));
self::assertSame(409, $this->responseCode());
}
public function testAServiceTheResourceDoesNotOfferIsRefused(): void
{
[$user, , $resource, $address] = $this->doctorWithResource();
$service = $this->service($address, 'ویزیت عمومی', 15); // بدون offering
$this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($resource, $this->tomorrowAt(12), [
'service_item_uuids' => [$service->getUuid()],
]));
self::assertSame(422, $this->responseCode());
}
public function testAnUnknownResourceIsRefused(): void
{
[$user, , , $address] = $this->doctorWithResource();
$service = $this->service($address, 'لیزر پا', 20);
$this->authJson('POST', '/api/v1/my/appointment', $user, [
'resource_uuid' => 'no-such-resource',
'slot_start' => $this->tomorrowAt(13),
'slot_end' => $this->tomorrowAt(13) + 1200,
'duration_from_services' => true,
'service_item_uuids' => [$service->getUuid()],
'patient_mobile' => '09121234567',
'patient_name' => 'بیمار تست',
'patient_national_code' => '0012345678',
]);
self::assertSame(422, $this->responseCode());
}
/** منبعِ محیط دیگر روی نوبت این محیط نمی‌نشیند. */
public function testAResourceOfAnotherEnvironmentIsRefused(): void
{
[$user, , , $address] = $this->doctorWithResource();
[, , $foreignResource, $foreignAddress] = $this->doctorWithResource();
$service = $this->service($address, 'لیزر صورت', 30);
$foreign = $this->service($foreignAddress, 'لیزر صورت', 30);
$this->offer($foreignResource, $foreign, 30);
$this->authJson('POST', '/api/v1/my/appointment', $user, $this->body($foreignResource, $this->tomorrowAt(15), [
'doctor_uuid' => $this->em->getRepository(Doctor::class)->findByUser($user)->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]));
self::assertSame(422, $this->responseCode());
}
}