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);