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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user