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(); } /** * 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 * @return array|null Fila de turnero_turnos o null si cola vacía */ function siguienteTurnoEnCola(string $estado, ?int $lugarId = null): ?array { $pdo = db(); if ($lugarId !== null) { $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 = ? ORDER BY p.orden_peso ASC, t.creado_at ASC LIMIT 1 '; $stmt = $pdo->prepare($sql); $stmt->execute([$estado, $lugarId]); } 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 '; $stmt = $pdo->prepare($sql); $stmt->execute([$estado]); } $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } /** * 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 } }