getConnection(); // --- Input --- $shop_id = (int)($_GET['shop'] ?? 0); $service_id = (int)($_GET['service'] ?? 0); $staff_id = (int)($_GET['staff'] ?? 0); $date = trim($_GET['date'] ?? ''); // formato Y-m-d if ($shop_id <= 0 || $service_id <= 0 || $staff_id <= 0 || $date === '') { echo json_encode(['ok' => false, 'error' => 'missing_params']); exit; } // Valida la data $d = DateTime::createFromFormat('Y-m-d', $date); if (!$d || $d->format('Y-m-d') !== $date) { echo json_encode(['ok' => false, 'error' => 'invalid_date']); exit; } try { // --- 1. Servizio: durata (indispensabile per capire se lo slot "ci sta") --- $svc = $pdo->prepare("SELECT duration_minutes FROM services WHERE id = ? AND shop_id = ? AND is_active = 1"); $svc->execute([$service_id, $shop_id]); $service = $svc->fetch(PDO::FETCH_ASSOC); if (!$service) { echo json_encode(['ok' => false, 'error' => 'service_not_found']); exit; } $duration = (int)$service['duration_minutes']; if ($duration <= 0) $duration = 30; // fallback prudente // --- 2. Impostazioni salone (con fallback ai default se manca la riga) --- $st = $pdo->prepare("SELECT appointment_slot_interval, min_booking_notice_hours, max_booking_days_ahead, allowed_start_minutes FROM shop_settings WHERE shop_id = ?"); $st->execute([$shop_id]); $set = $st->fetch(PDO::FETCH_ASSOC) ?: []; $slotInterval = (int)($set['appointment_slot_interval'] ?? 30); if ($slotInterval <= 0) $slotInterval = 30; $minNoticeHours = (int)($set['min_booking_notice_hours'] ?? 2); $maxDaysAhead = (int)($set['max_booking_days_ahead'] ?? 90); // Minuti d'inizio consentiti (es. "00,30"). Se vuoto → nessun filtro extra. $allowedMinutes = []; if (!empty($set['allowed_start_minutes'])) { foreach (explode(',', $set['allowed_start_minutes']) as $m) { $m = trim($m); if ($m !== '' && is_numeric($m)) $allowedMinutes[] = (int)$m; } } // --- 3. Finestra temporale: la data richiesta è dentro i limiti? --- $today = new DateTime('today'); $reqDay = new DateTime($date . ' 00:00:00'); $maxDay = (clone $today)->modify("+{$maxDaysAhead} days"); if ($reqDay < $today) { echo json_encode(['ok' => true, 'slots' => []]); // data passata → nessuno slot exit; } if ($reqDay > $maxDay) { echo json_encode(['ok' => true, 'slots' => []]); // troppo in là nel tempo exit; } // --- 4. Il salone è chiuso quel giorno? (chiusure straordinarie/ricorrenti) --- // is_recurring=1 → confronto solo mese-giorno (ricorre ogni anno). $off = $pdo->prepare(" SELECT is_recurring, `date` FROM shop_day_off WHERE shop_id = ? AND ( `date` = ? OR ( is_recurring = 1 AND DATE_FORMAT(`date`, '%m-%d') = ? ) ) LIMIT 1 "); $off->execute([$shop_id, $date, $d->format('m-d')]); if ($off->fetch()) { echo json_encode(['ok' => true, 'slots' => [], 'closed' => true]); exit; } // --- 5. Orari di apertura per quel giorno della settimana --- // day_of_week: usiamo 1=lunedì ... 7=domenica (ISO). Adegua se il salone usa 0=domenica. $dow = (int)$d->format('N'); // 1..7 $hrs = $pdo->prepare("SELECT is_open, open_time, close_time, open_time_2, close_time_2 FROM shop_hours WHERE shop_id = ? AND day_of_week = ?"); $hrs->execute([$shop_id, $dow]); $hours = $hrs->fetch(PDO::FETCH_ASSOC); if (!$hours || (int)$hours['is_open'] !== 1) { echo json_encode(['ok' => true, 'slots' => [], 'closed' => true]); exit; } // Raccogliamo le fasce di apertura (mattina + eventuale pomeriggio) $ranges = []; if (!empty($hours['open_time']) && !empty($hours['close_time'])) { $ranges[] = [$hours['open_time'], $hours['close_time']]; } if (!empty($hours['open_time_2']) && !empty($hours['close_time_2'])) { $ranges[] = [$hours['open_time_2'], $hours['close_time_2']]; } if (empty($ranges)) { echo json_encode(['ok' => true, 'slots' => [], 'closed' => true]); exit; } // --- 6. Appuntamenti già presi per quello staff in quel giorno --- // Solo pending/confirmed/completed occupano; cancelled/no_show liberano. $ap = $pdo->prepare(" SELECT start_at, end_at FROM appointments WHERE staff_id = ? AND DATE(start_at) = ? AND status IN ('pending','confirmed','completed') "); $ap->execute([$staff_id, $date]); $busy = []; foreach ($ap->fetchAll(PDO::FETCH_ASSOC) as $row) { $busy[] = [strtotime($row['start_at']), strtotime($row['end_at'])]; } // --- 7. Soglia minima: non prima di adesso + preavviso minimo --- $earliest = (new DateTime())->modify("+{$minNoticeHours} hours"); // --- 8. Generazione slot --- $slots = []; foreach ($ranges as [$openStr, $closeStr]) { $open = new DateTime($date . ' ' . $openStr); $close = new DateTime($date . ' ' . $closeStr); $cursor = clone $open; while (true) { // Fine servizio se iniziasse a $cursor $end = (clone $cursor)->modify("+{$duration} minutes"); // Lo slot deve stare INTERO dentro la fascia if ($end > $close) break; $startTs = $cursor->getTimestamp(); $endTs = $end->getTimestamp(); $valid = true; // Filtro minuti d'inizio consentiti (es. solo :00 e :30) if (!empty($allowedMinutes) && !in_array((int)$cursor->format('i'), $allowedMinutes, true)) { $valid = false; } // Non nel passato / rispetta preavviso minimo if ($valid && $cursor < $earliest) { $valid = false; } // Non deve sovrapporsi a un appuntamento esistente if ($valid) { foreach ($busy as [$bStart, $bEnd]) { if ($startTs < $bEnd && $endTs > $bStart) { // overlap classico $valid = false; break; } } } if ($valid) { $slots[] = $cursor->format('H:i'); } $cursor->modify("+{$slotInterval} minutes"); } } // Ordina e deduplica (se le fasce si toccano) $slots = array_values(array_unique($slots)); sort($slots); echo json_encode(['ok' => true, 'slots' => $slots]); } catch (Throwable $e) { error_log('book_slots: ' . $e->getMessage()); echo json_encode(['ok' => false, 'error' => 'server_error']); exit; }