Files
whatsapp/modules/turnero/api/sse_turno.php
T
Lizandro GuarnizoandClaude Fable 5 c9b3ebc179 Pantalla TV en ventana de PC: despertador SSE, y el SSE estaba mudo
El televisor no es un equipo dedicado: es una ventana de un computador de uso
mixto. Si esa ventana queda tapada o minimizada, Chrome congela sus relojes a
~1 tic por minuto, el sondeo se paraliza y los llamados intermedios no suenan
(reproducido y documentado en la bitácora el 23/08 a las 12:11).

Los eventos de red no se congelan. La pantalla se suscribe ahora al canal SSE
que ya existía —todos los caminos de llamado actualizan sse_ping_at: llamar,
rellamar, cambiar_estado y llamar_desde_espera— y cada cola_update dispara un
refresco inmediato, aunque la pestaña esté de fondo. El sondeo de 1 s queda
de respaldo, y el vigía de congelamiento anota en la bitácora si ambos fallan.

Al verificar en producción (sin dar por hecho que el canal servía), el SSE
resultó estar MUDO: cero bytes en 17 segundos, ni el keep-alive. Causa: el
arranque del ERP deja un búfer de salida activo —los jsonOk() de _helpers
hacen ob_clean(), que lo confirma— y sse_turno.php usaba flush() a secas, que
no lo atraviesa: todo lo emitido quedaba atrapado. Se drena el búfer al
iniciar el stream (ob_end_clean + ob_implicit_flush). Verificado ejecutándolo
contra la base real: emite cola_update de inmediato.

La suite vigila que la suscripción exista (17 verificaciones de voz).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:26:40 -05:00

166 lines
6.0 KiB
PHP

<?php
/**
* GET /modules/turnero/api/sse_turno.php
* Server-Sent Events — actualiza pantallas TV y kiosko en tiempo real.
* No requiere autenticación (acceso público).
*
* Query params:
* area string opcional "recepcion" | "lugar" (default: "recepcion")
* lugar_id int requerido si area = "lugar"
*
* Eventos emitidos:
* cola_update — estado completo de la cola cada vez que haya cambio o cada 30 s
* ping — comentario de keep-alive cada 15 s
*/
// ── Asegurar que la columna sse_ping_at exista (idempotente) ──
require_once __DIR__ . '/_helpers.php';
$pdo = db();
$pdo->exec(
"ALTER TABLE turnero_sesiones
ADD COLUMN IF NOT EXISTS sse_ping_at DATETIME NULL DEFAULT NULL"
);
// ── Headers SSE ───────────────────────────────────────────────
@set_time_limit(0);
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', false);
header('Content-Type: text/event-stream; charset=UTF-8');
header('Cache-Control: no-store, no-cache');
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
header('Connection: keep-alive');
// El arranque del ERP deja un búfer de salida activo (los jsonOk() de
// _helpers hacen ob_clean(), que lo confirma). flush() a secas no lo
// atraviesa: TODO lo emitido quedaba atrapado y el cliente recibía cero
// bytes, para siempre. Por eso este endpoint parecía muerto en producción.
while (ob_get_level() > 0) { @ob_end_clean(); }
@ob_implicit_flush(true);
// ── Parámetros ────────────────────────────────────────────────
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
if (!in_array($area, ['recepcion', 'lugar'], true)) {
$area = 'recepcion';
}
// ── Helpers de emisión ────────────────────────────────────────
function sseEvent(string $event, mixed $payload): void
{
echo "event: {$event}\n";
echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
}
function ssePing(): void
{
echo ": ping\n\n";
flush();
}
// ── Función que arma el snapshot de la cola ───────────────────
function buildSnapshot(PDO $pdo, int $sesionId, string $area, ?int $lugarId): array
{
if ($area === 'recepcion') {
$estadosCola = ["'espera'", "'en_recepcion'"];
$filtroLugar = '';
$bindsCola = [$sesionId];
$estadoActivo = "'en_recepcion'";
$campoLlamado = 'llamado_recepcion_at';
$bindActivo = [$sesionId];
$filtroActivo = '';
} else {
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
$filtroLugar = 'AND t.lugar_destino_id = ?';
$bindsCola = [$sesionId, $lugarId];
$estadoActivo = "'en_servicio'";
$campoLlamado = 'llamado_lugar_at';
$bindActivo = [$sesionId, $lugarId];
$filtroActivo = 'AND t.lugar_destino_id = ?';
}
$inEstados = implode(', ', $estadosCola);
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre, t.creado_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
p.color AS prioridad_color, p.orden_peso
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado IN ({$inEstados})
{$filtroLugar}
ORDER BY p.orden_peso ASC, t.creado_at ASC"
);
$stmt->execute($bindsCola);
$cola = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
t.{$campoLlamado} AS llamado_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado = {$estadoActivo}
{$filtroActivo}
ORDER BY t.{$campoLlamado} DESC
LIMIT 1"
);
$stmt->execute($bindActivo);
$activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
return [
'activo' => $activo,
'cola' => $cola,
'timestamp' => date('c'),
];
}
// ── Loop principal ────────────────────────────────────────────
$sesionId = obtenerOCrearSesionHoy();
$lastKeepalive = time();
$lastEmit = 0;
// Emitir estado inicial inmediatamente
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
sseEvent('cola_update', $snapshot);
// Inicializar $lastPing con el valor ACTUAL (no null) para que la
// primera iteración del loop no emita un snapshot extra sin cambios
$stmt = $pdo->prepare('SELECT sse_ping_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$lastPing = $stmt->fetchColumn() ?: null;
while (!connection_aborted()) {
sleep(1);
// Leer el último sse_ping_at de la sesión
$stmt = $pdo->prepare('SELECT sse_ping_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$pingActual = $row['sse_ping_at'] ?? null;
// Emitir si hubo cambio (notificarSSE actualiza sse_ping_at) o cada 30 s
$ahora = time();
$forzar = ($ahora - $lastEmit) >= 30;
if ($pingActual !== $lastPing || $forzar) {
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
sseEvent('cola_update', $snapshot);
$lastPing = $pingActual;
$lastEmit = $ahora;
}
// Keep-alive cada 15 s
if (($ahora - $lastKeepalive) >= 15) {
ssePing();
$lastKeepalive = $ahora;
}
}