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
@@ -47,6 +47,8 @@ class MyAppointmentsController extends BaseController
private readonly VisitPriceRequirementResolver $visitPriceResolver,
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
private readonly \App\Appointment\Repository\WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
) {}
/**
@@ -121,6 +123,31 @@ class MyAppointmentsController extends BaseController
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
$isReserve = (bool) ($data['is_reserve'] ?? false);
/**
* نوبتِ یک منبع (دستگاه/اتاق/پرسنل) — همان فرمِ نوبت، با هدفِ منبع.
*
* پزشک از ناظرِ منبع استنتاج می‌شود: رابطهٔ پزشک↔منبع یک جا تعریف شده (فرم
* منبع) و پرسیدن دوباره‌اش در فرم نوبت یعنی دو منبعِ حقیقت.
*/
$resourceUuid = trim((string) ($data['resource_uuid'] ?? ''));
$resource = null;
if ($resourceUuid !== '') {
$resource = $this->resourceRepo->findByUuid($resourceUuid);
if ($resource === null || !$resource->isActive()) {
return $this->error(ErrorCodes::VALIDATION, 'منبع یافت نشد', 422, 'resource_uuid');
}
if ($doctorUuid === '') {
$doctorUuid = $resource->getSupervisor()?->getUuid() ?? '';
}
if ($doctorUuid === '') {
return $this->error(ErrorCodes::VALIDATION, 'این منبع پزشک ناظر ندارد', 422, 'resource_uuid');
}
}
// Reserve entries are day-level: only a date is picked in the UI, so
// slot_end may equal slot_start and the past-slot rule does not apply.
if ($isReserve && $slotEnd < $slotStart) {
@@ -137,7 +164,16 @@ class MyAppointmentsController extends BaseController
// مدتِ override منشی برای همین نوبت (پیش‌فرض سرویس تغییر نمی‌کند). { uuid: minutes }
$durationOverrides = (array) ($data['service_durations'] ?? []);
$serviceItems = [];
if (!empty($serviceUuids) && !$isReserve) {
if (!empty($serviceUuids) && !$isReserve && $resource !== null) {
// نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع می‌آید (هر دستگاه مدت خودش را
// دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` می‌نشیند.
['minutes' => $resourceMinutes, 'items' => $serviceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides);
if ($computeDuration) {
$slotEnd = $slotStart + $resourceMinutes * 60;
}
} elseif (!empty($serviceUuids) && !$isReserve) {
$totalMinutes = 0;
foreach ($serviceUuids as $u) {
$item = $this->itemRepo->findByUuid($u);
@@ -231,6 +267,26 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::VALIDATION, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids');
}
if ($resource !== null) {
/**
* منبع با uuid از بدنه می‌آید و `TenantFilter` پوششش نمی‌دهد؛ بدون این
* بررسی، دستگاهِ کلینیک دیگری روی نوبت این کلینیک می‌نشست.
*/
if (!$this->tenantOwnership->belongsTo($bookingContext, $resource)) {
return $this->error(ErrorCodes::VALIDATION, 'منبع یافت نشد', 422, 'resource_uuid');
}
/**
* تداخل روی خودِ منبع جدا سنجیده می‌شود: `bookAtomically` فقط اسلاتِ پزشک
* را قفل می‌کند و دو پزشکِ متفاوت می‌توانند یک دستگاه را هم‌زمان بگیرند.
*/
if (!$isReserve && !$this->resourceSlots->isFree($resource, $slotStart, $slotEnd)) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این منبع در این زمان آزاد نیست', 409, 'resource_uuid');
}
$appointment->setResource($resource);
}
// پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
foreach ($serviceItems as $si) {
$appointment->addServiceItem($si);
@@ -113,6 +113,46 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->occupiedIntervals($doctor, $from, $to, true, $excludeId);
}
/**
* بازه‌های اشغال‌شدهٔ یک **منبع** در پنجرهٔ [$from, $to).
*
* همان معیارِ نوبتِ پزشک، ولی محورش `resource_id` است: منبع می‌تواند نوبت‌هایی از
* چند پزشک داشته باشد، پس فیلترِ پزشک اینجا هم غلط است و هم ناقص.
*
* ردیف‌های `resource_occupancy` اینجا نمی‌آیند — آن‌ها مسیرِ موتور منبع‌محورند و
* {@see \App\Resource\Service\ResourceBookingSlotService} هر دو را کنار هم می‌گذارد.
*
* @return list<array{start: int, end: int}> مرتب‌شده بر اساس start
*/
public function findResourceBusyIntervals(int $resourceId, int $from, int $to, ?int $excludeId = null): array
{
$qb = $this->createQueryBuilder('a')
->select('a.slotStart AS start, a.slotEnd AS end')
->where('IDENTITY(a.resource) = :resource')
->andWhere('a.slotStart < :to')
->andWhere('a.slotEnd > :from')
->andWhere('a.isReserve = false')
->andWhere(
'a.status IN (:blocking) OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
)
->setParameter('resource', $resourceId)
->setParameter('blocking', Appointment::SLOT_BLOCKING_STATUSES)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('now', time())
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('a.slotStart', 'ASC');
if ($excludeId !== null) {
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return array_map(
static fn (array $r): array => ['start' => (int) $r['start'], 'end' => (int) $r['end']],
$qb->getQuery()->getScalarResult(),
);
}
/**
* همان مجموعه‌ای که isSlotTaken() یک اسلات را با آن می‌سنجد — شامل نوبت‌های
* رزروی. برای پیمایش چندروزه که نمی‌خواهد به ازای هر اسلات یک کوئری بزند.