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
+282
View File
@@ -0,0 +1,282 @@
<?php
namespace App\Tests\Resource;
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\ResourceCalendar;
use App\Resource\Entity\ResourceServiceOffering;
use App\Shared\Context\EntityContext;
/**
* زمان‌های نوبت‌دهیِ یک منبع — همان چیزی که تایم‌لاین و مودالِ «ثبت نوبت» صفحهٔ
* نوبت‌ها مصرف می‌کنند.
*
* منبع اسلات ثابت ندارد: بازهٔ کاری از تقویم خودش می‌آید و زمان‌ها از مدتِ سرویس‌های
* انتخاب‌شده ساخته می‌شوند.
*/
class ResourceBookingSlotTest extends ResourceTestCase
{
private const SHIFT_FROM = 540; // ۰۹:۰۰
private const SHIFT_TO = 1020; // ۱۷:۰۰
/** @return array{0: User, 1: Doctor, 2: ClinicResource, 3: DoctorAddress} */
private function resourceWithShift(): array
{
[$user, $doctor, $address] = $this->doctorWithAddress();
$resource = new ClinicResource($address, $this->resourceType($address), 'لیزر CO2');
$resource->setSupervisor($doctor);
$this->em->persist($resource);
$this->em->flush();
$this->em->persist(new ResourceCalendar(
$resource,
$this->dayOfWeek($this->midnight()),
self::SHIFT_FROM,
self::SHIFT_TO,
));
$this->em->flush();
return [$user, $doctor, $resource, $address];
}
private function offer(ClinicResource $resource, DoctorAddress $address, string $name, ?int $minutes): ServiceItem
{
$section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name);
$this->em->persist($section);
$item = new ServiceItem($section, $name, 5_000_000);
$item->setBookable(true);
$this->em->persist($item);
$this->em->flush();
$offering = new ResourceServiceOffering($resource, $item);
$offering->setDurationMinutes($minutes);
$this->em->persist($offering);
$this->em->flush();
return $item;
}
/** فردا انتخاب می‌شود تا «زمانِ گذشته» نتیجه را کوتاه نکند. */
private function midnight(): int
{
return (int) strtotime('tomorrow midnight');
}
private function date(): string
{
return date('Y-m-d', $this->midnight());
}
/** ۰ = شنبه، همان قرارداد تقویم منبع. */
private function dayOfWeek(int $timestamp): int
{
return ((int) date('w', $timestamp) + 1) % 7;
}
private function bookOnResource(Doctor $doctor, User $user, ClinicResource $resource, int $start, int $end): void
{
$appointment = new Appointment($doctor, $user, $start, $end);
$appointment->setResource($resource);
$appointment->assignTenant(EntityContext::forBooking($doctor, null));
$this->em->persist($appointment);
$this->em->flush();
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testTheDayViewReturnsTheResourceShiftAsItsWorkingWindow(): void
{
[$user, , $resource] = $this->resourceWithShift();
$body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$this->date()}", $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertNull($body['data']['empty_reason']);
self::assertCount(1, $body['data']['windows']);
self::assertSame($this->midnight() + self::SHIFT_FROM * 60, $body['data']['windows'][0]['start']);
self::assertSame($this->midnight() + self::SHIFT_TO * 60, $body['data']['windows'][0]['end']);
}
public function testServiceSlotsAreChainedFromTheResourceDurationOfEachService(): void
{
[$user, , $resource, $address] = $this->resourceWithShift();
$laser = $this->offer($resource, $address, 'لیزر زیربغل', 25);
$rf = $this->offer($resource, $address, 'RF فرکشنال', 35);
$body = $this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s',
$resource->getUuid(),
$this->date(),
$laser->getUuid(),
$rf->getUuid(),
), $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(60, $body['data']['total_duration_minutes']);
// ۸ ساعت شیفت ÷ ۶۰ دقیقه = ۸ زمان، پشت‌سرهم از ۰۹:۰۰.
self::assertCount(8, $body['data']['start_times']);
self::assertSame('09:00', $body['data']['start_times'][0]['start_time']);
self::assertSame('10:00', $body['data']['start_times'][1]['start_time']);
}
public function testAnAppointmentOnTheResourceRemovesItsTimeFromTheOffer(): void
{
[$user, $doctor, $resource, $address] = $this->resourceWithShift();
$service = $this->offer($resource, $address, 'لیزر صورت', 60);
$occupied = $this->midnight() + 10 * 3600;
$this->bookOnResource($doctor, $user, $resource, $occupied, $occupied + 3600);
$body = $this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s',
$resource->getUuid(),
$this->date(),
$service->getUuid(),
), $user);
$starts = array_column($body['data']['start_times'], 'start_time');
self::assertNotContains('10:00', $starts);
self::assertContains('09:00', $starts);
self::assertContains('11:00', $starts);
}
/** مدتِ فرستاده‌شده فقط همین محاسبه را جابه‌جا می‌کند، نه پیش‌فرضِ سرویس را. */
public function testAPerRequestDurationOverridesTheOffering(): void
{
[$user, , $resource, $address] = $this->resourceWithShift();
$service = $this->offer($resource, $address, 'کرایوتراپی', 25);
$body = $this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s&durations[%s]=120',
$resource->getUuid(),
$this->date(),
$service->getUuid(),
$service->getUuid(),
), $user);
self::assertSame(120, $body['data']['total_duration_minutes']);
self::assertCount(4, $body['data']['start_times']); // ۸ ساعت ÷ ۲ ساعت
}
// ── ⚠️ مرزی ─────────────────────────────────────────────────────────────
/** اتاق دوتخته با یک نوبت پر نمی‌شود. */
public function testCapacityKeepsTheTimeOnOfferUntilItIsFull(): void
{
[$user, $doctor, $resource, $address] = $this->resourceWithShift();
$resource->setCapacity(2);
$this->em->flush();
$service = $this->offer($resource, $address, 'اکسیژن‌تراپی', 60);
$occupied = $this->midnight() + 10 * 3600;
$this->bookOnResource($doctor, $user, $resource, $occupied, $occupied + 3600);
$body = $this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s',
$resource->getUuid(),
$this->date(),
$service->getUuid(),
), $user);
self::assertContains('10:00', array_column($body['data']['start_times'], 'start_time'));
}
/** روزِ بی‌شیفت خطا نیست؛ باید بگوید چرا خالی است. */
public function testADayWithoutAShiftExplainsItself(): void
{
[$user, , $resource] = $this->resourceWithShift();
$otherDay = date('Y-m-d', $this->midnight() + 3 * 86400);
$body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$otherDay}", $user);
self::assertSame(200, $this->responseCode());
self::assertSame([], $body['data']['windows']);
self::assertNotNull($body['data']['empty_reason']);
}
/** رزرو موقتِ موتور منبع‌محور هم وقت را می‌گیرد، نه فقط نوبتِ ثبت‌شده. */
public function testAHoldFromTheResourceEngineAlsoBlocksTheTime(): void
{
[$user, , $resource, $address] = $this->resourceWithShift();
$service = $this->offer($resource, $address, 'مزوتراپی', 60);
$held = $this->midnight() + 12 * 3600;
$this->em->persist(new ResourceOccupancy($resource, $held, $held + 3600, ResourceOccupancy::STATUS_HOLD));
$this->em->flush();
$body = $this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s',
$resource->getUuid(),
$this->date(),
$service->getUuid(),
), $user);
self::assertNotContains('12:00', array_column($body['data']['start_times'], 'start_time'));
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testAServiceThisResourceDoesNotOfferIsRefused(): void
{
[$user, , $resource, $address] = $this->resourceWithShift();
$section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش دیگر');
$this->em->persist($section);
$foreign = new ServiceItem($section, 'سرویس بی‌ربط', 1_000_000);
$foreign->setDurationMinutes(20)->setBookable(true);
$this->em->persist($foreign);
$this->em->flush();
$this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s',
$resource->getUuid(),
$this->date(),
$foreign->getUuid(),
), $user);
self::assertSame(422, $this->responseCode());
}
public function testAnOfferingWithoutADurationIsRefused(): void
{
[$user, , $resource, $address] = $this->resourceWithShift();
$service = $this->offer($resource, $address, 'بدون مدت', null);
$this->authJson('GET', sprintf(
'/api/v1/resource/%s/service-slots?date=%s&service_item_uuids[]=%s',
$resource->getUuid(),
$this->date(),
$service->getUuid(),
), $user);
self::assertSame(422, $this->responseCode());
}
public function testAMalformedDateIsRefused(): void
{
[$user, , $resource] = $this->resourceWithShift();
$this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date=05-08-2026", $user);
self::assertSame(422, $this->responseCode());
}
public function testAnotherEnvironmentDoesNotSeeThisResource(): void
{
[, , $resource] = $this->resourceWithShift();
[$stranger] = $this->doctorWithAddress();
$this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/day-slots?date={$this->date()}", $stranger);
self::assertSame(404, $this->responseCode());
}
}