101 lines
4.7 KiB
PHP
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);
|
|
}
|
|
}
|