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:
@@ -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() یک اسلات را با آن میسنجد — شامل نوبتهای
|
||||
* رزروی. برای پیمایش چندروزه که نمیخواهد به ازای هر اسلات یک کوئری بزند.
|
||||
|
||||
Reference in New Issue
Block a user