Files
NextChair/app/Services/Availability/AvailabilityService.php
T
Stefan Lazic 2ef3654841
CI / ci (push) Canceled after 0s
PHP Rebuild
Initial PHP rebuild of the app
2026-09-05 08:21:33 +01:00

101 lines
4.7 KiB
PHP

<?php
namespace App\Services\Availability;
use App\Enums\AppointmentStatus;
use App\Models\Business;
use App\Models\Service;
use App\Models\StaffProfile;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class AvailabilityService
{
public function slots(Business $business, CarbonImmutable $date, array $serviceIds, ?string $staffId = null): Collection
{
$services = Service::query()
->where('businessId', $business->id)
->where('isActive', true)
->where('allowOnlineBooking', true)
->whereIn('id', $serviceIds)
->get();
if ($services->count() !== count(array_unique($serviceIds))) {
return collect();
}
$totalMinutes = $services->sum('durationMinutes') + $services->sum('bufferBeforeMinutes') + $services->sum('bufferAfterMinutes');
$rule = $business->bookingRule;
$interval = max(5, (int) ($rule?->slotIntervalMinutes ?? 30));
$businessTimezone = $business->timezone ?: 'UTC';
$localDate = $date->setTimezone($businessTimezone)->startOfDay();
$weekday = strtoupper($localDate->format('l'));
$businessHours = $business->hours()->where('dayOfWeek', $weekday)->first();
if (! $businessHours || $businessHours->isClosed || $businessHours->opensAtMinutes === null || $businessHours->closesAtMinutes === null) {
return collect();
}
$staffQuery = StaffProfile::query()
->where('businessId', $business->id)
->where('status', 'ACTIVE')
->where('isBookableOnline', true)
->when($staffId, fn ($query) => $query->whereKey($staffId))
->whereHas('services', fn ($query) => $query->whereIn('Service.id', $serviceIds)->groupBy('StaffProfile.id')->havingRaw('COUNT(DISTINCT Service.id) = ?', [count(array_unique($serviceIds))]));
return $staffQuery->with(['user', 'workingHours', 'breaks.days', 'timeOff'])->get()->flatMap(function (StaffProfile $staff) use ($business, $localDate, $weekday, $totalMinutes, $interval, $businessHours): Collection {
$hours = $staff->workingHours->firstWhere('dayOfWeek', $weekday);
if (! $hours || $hours->isOff || $hours->startMinutes === null || $hours->endMinutes === null) {
return collect();
}
$startMinute = max($hours->startMinutes, $businessHours->opensAtMinutes);
$endMinute = min($hours->endMinutes, $businessHours->closesAtMinutes);
$slots = collect();
for ($minute = $startMinute; $minute + $totalMinutes <= $endMinute; $minute += $interval) {
$start = $localDate->addMinutes($minute);
$end = $start->addMinutes($totalMinutes);
$blocked = $staff->breaks->contains(fn ($break) => $this->overlapsRecurringBreak($break, $weekday, $minute, $minute + $totalMinutes))
|| $staff->timeOff->contains(fn ($timeOff) => $this->overlapsTimeOff($timeOff, $localDate, $minute, $minute + $totalMinutes));
if ($blocked || $start->lessThan(CarbonImmutable::now($business->timezone)->addMinutes((int) ($business->bookingRule?->minNoticeMinutes ?? 120)))) {
continue;
}
$hasConflict = $staff->appointments()
->whereNotIn('status', [AppointmentStatus::Cancelled->value, AppointmentStatus::NoShow->value])
->where('startAt', '<', $end->utc())
->where('endAt', '>', $start->utc())
->exists();
if (! $hasConflict) {
$slots->push([
'staffId' => $staff->id,
'staffName' => $staff->user?->name ?: 'Available barber',
'startAt' => $start->toIso8601String(),
'endAt' => $end->toIso8601String(),
'label' => $start->format('g:i A'),
]);
}
}
return $slots;
})->sortBy('startAt')->values();
}
private function overlapsRecurringBreak(object $break, string $weekday, int $start, int $end): bool
{
$applies = $break->days->contains('dayOfWeek', $weekday);
return $applies && $break->startMinutes < $end && $break->endMinutes > $start;
}
private function overlapsTimeOff(object $timeOff, CarbonImmutable $date, int $start, int $end): bool
{
if ($date->lessThan($timeOff->startDate->startOfDay()) || $date->greaterThan($timeOff->endDate->endOfDay())) {
return false;
}
return $timeOff->startMinutes === null || ($timeOff->startMinutes < $end && $timeOff->endMinutes > $start);
}
}