Files
ladill-merchant/app/Services/Merchant/BookingSlotService.php
T
isaaccladandCursor f718b9cfbf Initial Ladill Merchant app with Gitea deploy pipeline.
Shop, menu, and booking storefronts with OIDC SSO, Pay checkout, and merchant.ladill.com deployment workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:05:21 +00:00

117 lines
3.7 KiB
PHP

<?php
namespace App\Services\Merchant;
use App\Models\QrBooking;
use App\Models\QrCode;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class BookingSlotService
{
/** @var list<string> */
private const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
/**
* @return list<array{starts_at: string, ends_at: string, label: string}>
*/
public function availableSlots(QrCode $qrCode, string $date, int $serviceIndex): array
{
$content = $qrCode->content();
$services = $content['services'] ?? [];
$service = $services[$serviceIndex] ?? null;
if (! is_array($service)) {
return [];
}
$duration = max(15, (int) ($service['duration_minutes'] ?? 30));
$day = Carbon::parse($date)->startOfDay();
if ($day->isPast() && ! $day->isToday()) {
return [];
}
$windows = $this->windowsForDay($content, $day);
if ($windows === []) {
return [];
}
$booked = QrBooking::query()
->where('qr_code_id', $qrCode->id)
->whereIn('status', [QrBooking::STATUS_PENDING, QrBooking::STATUS_CONFIRMED])
->whereDate('starts_at', $day->toDateString())
->get(['starts_at', 'ends_at']);
$slots = [];
foreach ($windows as $window) {
$cursor = $day->copy()->setTimeFromTimeString($window['start']);
$windowEnd = $day->copy()->setTimeFromTimeString($window['end']);
while ($cursor->copy()->addMinutes($duration)->lte($windowEnd)) {
$slotEnd = $cursor->copy()->addMinutes($duration);
if ($day->isToday() && $cursor->lte(now())) {
$cursor->addMinutes($duration);
continue;
}
if (! $this->overlapsBooked($cursor, $slotEnd, $booked)) {
$slots[] = [
'starts_at' => $cursor->toIso8601String(),
'ends_at' => $slotEnd->toIso8601String(),
'label' => $cursor->format('g:i A'),
];
}
$cursor->addMinutes($duration);
}
}
return $slots;
}
/** @return list<array{start: string, end: string}> */
private function windowsForDay(array $content, Carbon $day): array
{
$key = self::DAY_KEYS[$day->dayOfWeek];
$availability = $content['availability'] ?? [];
$windows = $availability[$key] ?? [];
if (! is_array($windows) || $windows === []) {
$opens = (string) ($content['opens_at'] ?? '09:00');
$closes = (string) ($content['closes_at'] ?? '17:00');
$activeDays = $content['active_days'] ?? ['mon', 'tue', 'wed', 'thu', 'fri'];
if (! in_array($key, (array) $activeDays, true)) {
return [];
}
return [['start' => $opens, 'end' => $closes]];
}
$normalized = [];
foreach ($windows as $window) {
if (! is_array($window)) {
continue;
}
$start = trim((string) ($window['start'] ?? ''));
$end = trim((string) ($window['end'] ?? ''));
if ($start !== '' && $end !== '') {
$normalized[] = ['start' => $start, 'end' => $end];
}
}
return $normalized;
}
/** @param Collection<int, QrBooking> $booked */
private function overlapsBooked(Carbon $start, Carbon $end, Collection $booked): bool
{
foreach ($booked as $booking) {
if ($start < $booking->ends_at && $end > $booking->starts_at) {
return true;
}
}
return false;
}
}