false, 'error' => 'Error interno: ' . $err['message']]); } }); header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } // ── Funciones de respuesta ─────────────────────────────────── function jsonOk(array $payload = [], string $mensaje = ''): void { ob_clean(); $resp = ['ok' => true, 'success' => true]; if ($mensaje) { $resp['message'] = $mensaje; } echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE); exit; } function jsonError(string $mensaje, int $code = 400): void { ob_clean(); http_response_code($code); echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE); exit; } function requireMethod(string $method): void { if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) { jsonError('Método no permitido', 405); } } function inputJson(): array { static $parsed = null; if ($parsed === null) { $raw = file_get_contents('php://input'); $parsed = json_decode($raw, true) ?? []; } return $parsed; } // ── Autenticación (kiosko y display son públicos) ──────────── /** * Exige sesión activa. Para endpoints de login requerido. */ function requireAuth(): void { if (empty($_SESSION['admin_user']['id'])) { jsonError('No autenticado', 401); } } /** * Exige sesión + módulo turnero. */ function requireTurnero(): void { requireAuth(); $modules = $_SESSION['admin_user']['modules'] ?? []; $roleId = $_SESSION['admin_user']['role_id'] ?? null; // Superadmin / admin sin role_id = acceso total if (empty($roleId)) { return; } if (!in_array('turnero', (array) $modules, true)) { jsonError('Sin acceso al módulo Turnero', 403); } } function adminId(): ?int { return isset($_SESSION['admin_user']['id']) ? (int) $_SESSION['admin_user']['id'] : null; } // ── Helper BD ──────────────────────────────────────────────── function db(): PDO { return Database::getInstance()->getConnection(); } // ── Motor de cola ──────────────────────────────────────────── /** * Obtiene o crea la sesión del día actual. * Devuelve el id de la sesión. */ function obtenerOCrearSesionHoy(): int { $pdo = db(); $hoy = date('Y-m-d'); $stmt = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1'); $stmt->execute([$hoy]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { return (int) $row['id']; } // Crear sesión del día $stmt = $pdo->prepare( 'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())' ); $stmt->execute([$hoy, adminId()]); return (int) $pdo->lastInsertId(); } /** * Devuelve ['id', 'fin_at'] de la sesión de hoy si existe, o null. * Para uso en pantallas: NO crea sesión nueva. */ function sesionHoy(): ?array { $pdo = db(); $stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = CURDATE() LIMIT 1'); $stmt->execute(); return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; } /** * Genera el siguiente número correlativo de turno para una sesión. * Usa bloqueo a nivel de fila para evitar duplicados en concurrencia. */ function siguienteNumero(int $sesionId): int { $pdo = db(); $stmt = $pdo->prepare( 'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente FROM turnero_turnos WHERE sesion_id = ? FOR UPDATE' ); $stmt->execute([$sesionId]); return (int) $stmt->fetchColumn(); } /** * Genera el siguiente número correlativo de turno POR PRIORIDAD para una sesión. * Cada prioridad (A, B, C, D, E, F) tiene su propia secuencia independiente. * Usa bloqueo a nivel de fila para evitar duplicados en concurrencia. */ function siguienteNumeroPorPrioridad(int $sesionId, int $prioridadId): int { $pdo = db(); $stmt = $pdo->prepare( 'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente FROM turnero_turnos WHERE sesion_id = ? AND prioridad_id = ? FOR UPDATE' ); $stmt->execute([$sesionId, $prioridadId]); return (int) $stmt->fetchColumn(); } /** * Selecciona el siguiente turno a llamar según el motor de prioridades. * * @param string $estado Estado que deben tener los turnos en cola * @param int|null $lugarId Si no es null, filtra por lugar_destino_id (o su grupo) * @param array|null $lugarIds Lista explícita de lugar_ids (grupo completo) * @return array|null Fila de turnero_turnos o null si cola vacía */ function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null): ?array { $pdo = db(); // Resolver grupo si se pasó un lugarId sin lista explícita if ($lugarId !== null && $lugarIds === null) { $lugarIds = lugarIdsDeGrupo($lugarId); } if (!empty($lugarIds)) { $in = implode(',', array_map('intval', $lugarIds)); $sql = " SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color FROM turnero_turnos t JOIN turnero_prioridades p ON p.id = t.prioridad_id WHERE t.estado = ? AND t.lugar_destino_id IN ($in) ORDER BY p.orden_peso ASC, t.creado_at ASC LIMIT 1 FOR UPDATE "; $stmt = $pdo->prepare($sql); $stmt->execute([$estado]); } else { $sql = ' SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color FROM turnero_turnos t JOIN turnero_prioridades p ON p.id = t.prioridad_id WHERE t.estado = ? ORDER BY p.orden_peso ASC, t.creado_at ASC LIMIT 1 FOR UPDATE '; $stmt = $pdo->prepare($sql); $stmt->execute([$estado]); } $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } /** * Devuelve todos los lugar_ids que comparten grupo con $lugarId. * Si no tiene grupo, devuelve [$lugarId]. */ function lugarIdsDeGrupo(int $lugarId): array { $pdo = db(); $stmt = $pdo->prepare("SELECT grupo_id FROM turnero_lugares WHERE id = ?"); $stmt->execute([$lugarId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row || $row['grupo_id'] === null) { return [$lugarId]; } $grupoId = (int)$row['grupo_id']; $stmt2 = $pdo->prepare("SELECT id FROM turnero_lugares WHERE grupo_id = ? AND activo = 1"); $stmt2->execute([$grupoId]); $ids = $stmt2->fetchAll(PDO::FETCH_COLUMN); return $ids ?: [$lugarId]; } /** * Emite un evento SSE a todos los clientes suscritos escribiendo * un flag en la tabla turnero_sesiones (campo sse_ping_at). * Los clientes hacen polling de ese campo o escuchan el SSE directamente. * * Implementación ligera: actualiza timestamp en sesión para que * sse_turno.php lo detecte y reenvíe el estado. */ function notificarSSE(int $sesionId): void { try { db()->prepare( 'UPDATE turnero_sesiones SET sse_ping_at = NOW() WHERE id = ?' )->execute([$sesionId]); } catch (\Throwable $e) { // No es crítico si falla; el SSE continuará con polling normal } }