This commit is contained in:
Lizandro Guarnizo
2026-04-18 23:41:01 -05:00
parent 876e935a66
commit 318a1742cc
37 changed files with 8838 additions and 89 deletions
+219
View File
@@ -0,0 +1,219 @@
<?php
/**
* Helpers compartidos para los endpoints del módulo Turnero.
* Incluido por cada endpoint de modules/turnero/api/.
*/
ob_start();
require_once __DIR__ . '/../../../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => 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();
}
/**
* 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
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* POST /modules/turnero/api/cambiar_estado.php
* Avanza o cancela el estado de un turno.
* Requiere login + módulo turnero.
*
* Body JSON:
* turno_id int requerido
* nuevo_estado string requerido (ver transiciones válidas abajo)
* lugar_id int requerido cuando nuevo_estado = "en_espera_lugar"
*
* Transiciones permettidas:
* espera → en_recepcion
* en_recepcion → en_espera_lugar, ausente, cancelado
* en_espera_lugar → en_servicio, ausente, cancelado
* en_servicio → finalizado, ausente, cancelado
* (cualquier) → ausente, cancelado
*
* Bloqueo especial:
* en_espera_lugar → en_servicio requiere que todos los consentimientos
* del turno estén en estado 'firmado' o 'rechazado'.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
// ── Validación básica ─────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
// Mapa de transiciones válidas [estado_actual => [estados_destino_permitidos]]
const TRANSICIONES = [
'espera' => ['en_recepcion', 'ausente', 'cancelado'],
'en_recepcion' => ['en_espera_lugar', 'ausente', 'cancelado'],
'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'],
'en_servicio' => ['finalizado', 'ausente', 'cancelado'],
];
$pdo = db();
$pdo->beginTransaction();
try {
// Leer turno actual con lock
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?
FOR UPDATE'
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
$pdo->rollBack();
jsonError('Turno no encontrado.', 404);
}
$estadoActual = $turno['estado'];
// Estados finales no modificables
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
// Verificar que la transición sea válida
$permitidos = TRANSICIONES[$estadoActual] ?? [];
if (!in_array($nuevoEstado, $permitidos, true)) {
$pdo->rollBack();
jsonError(
"Transición '{$estadoActual}' → '{$nuevoEstado}' no está permitida. "
. "Transiciones válidas: " . implode(', ', $permitidos),
422
);
}
// ── Regla especial: en_espera_lugar → en_servicio ─────────
if ($nuevoEstado === 'en_servicio') {
$cStmt = $pdo->prepare(
"SELECT COUNT(*) FROM turnero_consentimientos
WHERE turno_id = ?
AND estado NOT IN ('firmado', 'rechazado')"
);
$cStmt->execute([$turnoId]);
$pendientes = (int) $cStmt->fetchColumn();
if ($pendientes > 0) {
$pdo->rollBack();
jsonError(
"Hay {$pendientes} consentimiento(s) pendiente(s). "
. "El turno no puede pasar a 'en_servicio' hasta que todos estén firmados o rechazados.",
422
);
}
}
// ── Construir UPDATE dinámico ─────────────────────────────
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
switch ($nuevoEstado) {
case 'en_recepcion':
$sets[] = 'llamado_recepcion_at = COALESCE(llamado_recepcion_at, NOW())';
$sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())';
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
$binds[] = adminId();
break;
case 'en_espera_lugar':
// Requiere lugar_id
if (!$lugarId) {
$pdo->rollBack();
jsonError('lugar_id es obligatorio para pasar a en_espera_lugar.', 422);
}
$sets[] = 'fin_recepcion_at = COALESCE(fin_recepcion_at, NOW())';
$sets[] = 'lugar_destino_id = ?';
$binds[] = $lugarId;
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
$binds[] = adminId();
break;
case 'en_servicio':
$sets[] = 'llamado_lugar_at = COALESCE(llamado_lugar_at, NOW())';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
break;
case 'finalizado':
$sets[] = 'fin_lugar_at = COALESCE(fin_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
break;
case 'ausente':
case 'cancelado':
// Sin timestamps adicionales; simplemente cambio de estado
break;
}
$sets[] = 'actualizado_at = NOW()';
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
// Refrescar y devolver turno actualizado
$stmt = $pdo->prepare(
'SELECT t.*, 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.id = ?'
);
$stmt->execute([$turnoId]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonError('Error al cambiar estado: ' . $e->getMessage(), 500);
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* POST /modules/turnero/api/create_solicitud.php
* Crea la solicitud interna de un turno: vincula paciente, lugar y exámenes.
* Además devuelve qué consentimientos serán necesarios (sin enviarlos aún).
* Requiere login + módulo turnero.
*
* Body JSON:
* turno_id int requerido
* paciente_id int requerido
* lugar_id int requerido
* exam_tipo_ids int[] requerido (al menos 1)
* total_cobrado float opcional
* metodo_pago string opcional
* observaciones string opcional
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$pacienteId = isset($datos['paciente_id']) ? (int) $datos['paciente_id'] : 0;
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : 0;
$examIds = isset($datos['exam_tipo_ids']) && is_array($datos['exam_tipo_ids'])
? array_filter(array_map('intval', $datos['exam_tipo_ids'])) : [];
$total = isset($datos['total_cobrado']) && $datos['total_cobrado'] !== null
? (float) $datos['total_cobrado'] : null;
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
// ── Validaciones ──────────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($pacienteId <= 0) jsonError('paciente_id inválido.');
if ($lugarId <= 0) jsonError('lugar_id inválido.');
if (empty($examIds)) jsonError('Debe seleccionar al menos un examen.');
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', ''];
if ($metodoPago && !in_array($metodoPago, $metodosValidos, true)) {
jsonError('metodo_pago inválido.');
}
$pdo = db();
$pdo->beginTransaction();
try {
// Verificar que el turno existe y está en recepción
$stmt = $pdo->prepare(
"SELECT id, estado FROM turnero_turnos WHERE id = ? FOR UPDATE"
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
$pdo->rollBack();
jsonError('Turno no encontrado.', 404);
}
if (!in_array($turno['estado'], ['en_recepcion', 'espera'], true)) {
$pdo->rollBack();
jsonError("El turno está en estado '{$turno['estado']}' y no se puede editar su solicitud.", 422);
}
// Verificar que el lugar existe
$stmt = $pdo->prepare("SELECT id FROM turnero_lugares WHERE id = ? AND activo = 1");
$stmt->execute([$lugarId]);
if (!$stmt->fetch()) {
$pdo->rollBack();
jsonError('Lugar destino no encontrado o inactivo.', 404);
}
// Verificar que todos los exam_tipo_ids existen
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
$stmt->execute($examIds);
if ((int)$stmt->fetchColumn() !== count($examIds)) {
$pdo->rollBack();
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
}
// ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ──
$stmt = $pdo->prepare("SELECT id FROM turnero_solicitudes WHERE turno_id = ?");
$stmt->execute([$turnoId]);
$solAnterior = $stmt->fetch(PDO::FETCH_ASSOC);
if ($solAnterior) {
// Eliminar ítemes anteriores (CASCADE borrará los turnero_examen_items)
$pdo->prepare("DELETE FROM turnero_solicitudes WHERE turno_id = ?")->execute([$turnoId]);
}
$stmt = $pdo->prepare(
"INSERT INTO turnero_solicitudes
(turno_id, paciente_id, lugar_id, total_cobrado, metodo_pago, observaciones, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$turnoId, $pacienteId, $lugarId,
$total, $metodoPago ?: null, $obs ?: null, adminId(),
]);
$solicitudId = (int) $pdo->lastInsertId();
// ── Insertar ítemes de examen ──────────────────────────────
$stmtItem = $pdo->prepare(
"INSERT INTO turnero_examen_items (solicitud_id, exam_tipo_id) VALUES (?, ?)"
);
foreach ($examIds as $examId) {
$stmtItem->execute([$solicitudId, $examId]);
}
// ── Calcular consentimientos necesarios (sin crearlos todavía) ──
// Deduplica: varios exámenes que apuntan al mismo formulario → 1 solo ítem.
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre
FROM exam_tipo_consentimientos etc
JOIN lab_formularios f ON f.id = etc.formulario_id
WHERE etc.exam_tipo_id IN ({$in})"
);
$stmt->execute($examIds);
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Agregar estado actual de cada consentimiento (si ya fue enviado antes)
foreach ($consentimientosRequeridos as &$c) {
$stC = $pdo->prepare(
"SELECT estado FROM turnero_consentimientos WHERE turno_id = ? AND formulario_id = ?"
);
$stC->execute([$turnoId, $c['formulario_id']]);
$row = $stC->fetch(PDO::FETCH_ASSOC);
$c['estado'] = $row['estado'] ?? 'pendiente';
}
unset($c);
$pdo->commit();
notificarSSE(obtenerOCrearSesionHoy());
// Recuperar solicitud completa
$stmt = $pdo->prepare(
"SELECT s.*,
p.full_name AS paciente_nombre,
l.nombre AS lugar_nombre
FROM turnero_solicitudes s
JOIN lab_pacientes l2 ON l2.id = s.lugar_id -- placeholder; join real abajo
-- joins reales:
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
WHERE s.id = ?"
);
// Simplificado para evitar error de sintaxis de comentarios SQL embebidos:
$stmt = $pdo->prepare(
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id, s.total_cobrado,
s.metodo_pago, s.observaciones, s.creado_at,
p.full_name AS paciente_nombre,
p.documento AS paciente_documento,
l.nombre AS lugar_nombre
FROM turnero_solicitudes s
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
WHERE s.id = ?"
);
$stmt->execute([$solicitudId]);
$solicitud = $stmt->fetch(PDO::FETCH_ASSOC);
jsonOk([
'solicitud' => $solicitud,
'consentimientos_requeridos' => $consentimientosRequeridos,
], 'Solicitud guardada correctamente');
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
jsonError('Error al crear solicitud: ' . $e->getMessage(), 500);
}
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* POST /modules/turnero/api/create_turno.php
* Genera un nuevo turno desde el kiosko o recepción.
* No requiere login (kiosko es público).
*
* Body JSON:
* prioridad_codigo string requerido "A"|"B"|"C"|"D"|"E"|"F"
* paciente_nombre string opcional
* paciente_cel string opcional
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
// Este endpoint es público (kiosko no requiere login)
// Solo validamos CSRF básico: debe ser POST con JSON
$datos = inputJson();
// ── Validación ────────────────────────────────────────────────
$prioCodigo = strtoupper(trim($datos['prioridad_codigo'] ?? ''));
if (!in_array($prioCodigo, ['A', 'B', 'C', 'D', 'E', 'F'], true)) {
jsonError('prioridad_codigo inválido. Use A, B, C, D, E o F.');
}
$pacienteNombre = substr(trim($datos['paciente_nombre'] ?? ''), 0, 150);
$pacienteCel = preg_replace('/[^0-9+\- ]/', '', $datos['paciente_cel'] ?? '');
$pacienteCel = substr($pacienteCel, 0, 20);
// ── Lógica principal (dentro de transacción para evitar race conditions) ──
$pdo = db();
$pdo->beginTransaction();
try {
$sesionId = obtenerOCrearSesionHoy();
$numero = siguienteNumero($sesionId);
// Buscar prioridad_id
$stmt = $pdo->prepare(
'SELECT id FROM turnero_prioridades WHERE codigo = ? AND activo = 1 LIMIT 1'
);
$stmt->execute([$prioCodigo]);
$prioridad = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$prioridad) {
$pdo->rollBack();
jsonError("La prioridad '$prioCodigo' no existe o no está activa.", 422);
}
$prioridadId = (int) $prioridad['id'];
// Código visual: "A001", "E042", etc.
$codigo = $prioCodigo . str_pad($numero, 3, '0', STR_PAD_LEFT);
$stmt = $pdo->prepare(
'INSERT INTO turnero_turnos
(sesion_id, numero, codigo, prioridad_id, paciente_nombre, paciente_cel,
estado, creado_at)
VALUES
(?, ?, ?, ?, ?, ?, "espera", NOW())'
);
$stmt->execute([
$sesionId,
$numero,
$codigo,
$prioridadId,
$pacienteNombre ?: null,
$pacienteCel ?: null,
]);
$turnoId = (int) $pdo->lastInsertId();
$pdo->commit();
notificarSSE($sesionId);
// Contar posición en la cola
$stmt = $pdo->prepare(
'
SELECT COUNT(*) AS posicion
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.estado = "espera"
AND t.sesion_id = ?
AND (
p.orden_peso < (SELECT orden_peso FROM turnero_prioridades WHERE id = ?)
OR (
p.orden_peso = (SELECT orden_peso FROM turnero_prioridades WHERE id = ?)
AND t.creado_at < (SELECT creado_at FROM turnero_turnos WHERE id = ?)
)
)
'
);
$stmt->execute([$sesionId, $prioridadId, $prioridadId, $turnoId]);
$posicion = (int) $stmt->fetchColumn() + 1;
jsonOk([
'turno' => [
'id' => $turnoId,
'codigo' => $codigo,
'numero' => $numero,
'prioridad_codigo' => $prioCodigo,
'posicion_cola' => $posicion,
'sesion_id' => $sesionId,
],
], "Turno $codigo asignado correctamente");
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonError('Error al crear el turno: ' . $e->getMessage(), 500);
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* modules/turnero/api/export_csv.php
* GET ?fecha=YYYY-MM-DD → descarga CSV con todos los turnos del día
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// Buscar sesión del día
$stmtS = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
$stmtS->execute([$fecha]);
$sesion = $stmtS->fetch(PDO::FETCH_ASSOC);
if (!$sesion) {
// Sin sesión: CSV vacío con encabezado
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"');
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
echo "Sin datos para la fecha {$fecha}\n";
exit;
}
$sesionId = (int)$sesion['id'];
$stmt = $pdo->prepare(
"SELECT
t.codigo,
t.numero,
p.codigo AS prioridad,
p.nombre AS prioridad_nombre,
COALESCE(s.nombre_completo, t.paciente_nombre) AS paciente,
s.numero_documento,
s.tipo_documento,
t.paciente_cel AS celular,
l.nombre AS lugar,
t.estado,
t.creado_at,
t.llamado_recepcion_at,
t.inicio_recepcion_at,
t.fin_recepcion_at,
t.llamado_lugar_at,
t.inicio_lugar_at,
t.fin_lugar_at,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at, NOW())) / 60.0, 1)
AS espera_recepcion_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1)
AS servicio_lugar_min,
sol.total_cobrado,
sol.metodo_pago,
GROUP_CONCAT(et.nombre ORDER BY et.nombre SEPARATOR ' | ') AS examenes,
SUM(CASE WHEN tc.estado = 'firmado' THEN 1 ELSE 0 END) AS consent_firmados,
SUM(CASE WHEN tc.estado = 'pendiente' THEN 1 ELSE 0 END) AS consent_pendientes
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes sol ON sol.turno_id = t.id
LEFT JOIN lab_pacientes s ON s.id = sol.paciente_id
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = sol.id
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
LEFT JOIN turnero_consentimientos tc ON tc.turno_id = t.id
WHERE t.sesion_id = ?
GROUP BY t.id
ORDER BY t.numero ASC"
);
$stmt->execute([$sesionId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ── Encabezados CSV ───────────────────────────────────────────
$headers = [
'Código', 'Número', 'Prioridad', 'Prioridad nombre',
'Paciente', 'Documento', 'Tipo doc.', 'Celular',
'Lugar', 'Estado',
'Creado', 'Llamado recepción', 'Inicio recepción', 'Fin recepción',
'Llamado lugar', 'Inicio lugar', 'Fin lugar',
'Espera recepción (min)', 'Servicio lugar (min)',
'Total cobrado', 'Método pago',
'Exámenes',
'Consentimientos firmados', 'Consentimientos pendientes',
];
$fields = [
'codigo', 'numero', 'prioridad', 'prioridad_nombre',
'paciente', 'numero_documento', 'tipo_documento', 'celular',
'lugar', 'estado',
'creado_at', 'llamado_recepcion_at', 'inicio_recepcion_at', 'fin_recepcion_at',
'llamado_lugar_at', 'inicio_lugar_at', 'fin_lugar_at',
'espera_recepcion_min', 'servicio_lugar_min',
'total_cobrado', 'metodo_pago',
'examenes',
'consent_firmados', 'consent_pendientes',
];
// ── Output ────────────────────────────────────────────────────
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"');
header('Cache-Control: no-cache, no-store, must-revalidate');
$out = fopen('php://output', 'w');
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
fputcsv($out, $headers, ',', '"');
foreach ($rows as $row) {
$line = [];
foreach ($fields as $f) {
$v = $row[$f] ?? '';
// Formatear fechas: quitar segundos fraccionarios si los hay
if ($v && in_array($f, ['creado_at','llamado_recepcion_at','inicio_recepcion_at','fin_recepcion_at','llamado_lugar_at','inicio_lugar_at','fin_lugar_at'])) {
$v = $v ? date('d/m/Y H:i:s', strtotime($v)) : '';
}
$line[] = $v ?? '';
}
fputcsv($out, $line, ',', '"');
}
fclose($out);
exit;
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* GET /modules/turnero/api/get_cola.php
* Devuelve el estado de la cola para pantallas TV y kiosko.
* No requiere autenticación (acceso público).
*
* Query params:
* area string requerido "recepcion" | "lugar"
* lugar_id int requerido si area = "lugar"
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$area = isset($_GET['area']) ? trim($_GET['area']) : '';
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
if (!in_array($area, ['recepcion', 'lugar'], true)) {
jsonError('area debe ser "recepcion" o "lugar".');
}
if ($area === 'lugar' && !$lugarId) {
jsonError('lugar_id es obligatorio cuando area = "lugar".');
}
$pdo = db();
// ── Sesión de hoy ─────────────────────────────────────────────
$sesionId = obtenerOCrearSesionHoy();
// ── Cola según área ───────────────────────────────────────────
if ($area === 'recepcion') {
$estadosCola = ["'espera'", "'en_recepcion'"];
$filtroLugar = '';
$bindsCola = [];
} else {
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
$filtroLugar = 'AND t.lugar_destino_id = ?';
$bindsCola = [$lugarId];
}
$inEstados = implode(', ', $estadosCola);
$stmt = $pdo->prepare(
"SELECT t.id,
t.codigo,
t.numero,
t.estado,
t.paciente_nombre,
t.creado_at,
t.llamado_recepcion_at,
t.llamado_lugar_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"
);
array_unshift($bindsCola, $sesionId);
$stmt->execute($bindsCola);
$cola = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ── Turno actualmente en pantalla (último llamado) ────────────
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : '';
$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;
// ── Estadísticas de la sesión ─────────────────────────────────
$stmt = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(estado = 'espera') AS en_espera,
SUM(estado = 'en_recepcion') AS en_recepcion,
SUM(estado = 'en_espera_lugar') AS en_espera_lugar,
SUM(estado = 'en_servicio') AS en_servicio,
SUM(estado = 'finalizado') AS finalizados,
SUM(estado IN ('ausente','cancelado')) AS no_atendidos,
SEC_TO_TIME(
AVG(
CASE WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at)
ELSE NULL END
)
) AS tiempo_promedio_atencion
FROM turnero_turnos
WHERE sesion_id = ?"
);
$stmt->execute([$sesionId]);
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
jsonOk([
'sesion_id' => $sesionId,
'area' => $area,
'lugar_id' => $lugarId,
'activo' => $activo,
'cola' => $cola,
'stats' => $stats,
'timestamp' => date('c'),
]);
@@ -0,0 +1,98 @@
<?php
/**
* GET /modules/turnero/api/get_consentimientos.php
* Retorna el estado actualizado de los consentimientos de un turno.
* Usado por lugar.php (polling cada 5 s) y recepcion.php.
* No requiere autenticación (las pantallas internas ya están protegidas).
*
* Query params:
* turno_id int requerido
* incluir_solicitud int opcional 1 → también retorna solicitud, paciente y exámenes
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$turnoId = isset($_GET['turno_id']) ? (int) $_GET['turno_id'] : 0;
$incluirSolicitud = isset($_GET['incluir_solicitud']) ? (bool)(int)$_GET['incluir_solicitud'] : false;
if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── Verificar que el turno existe ─────────────────────────────
$stmt = $pdo->prepare("SELECT id, estado FROM turnero_turnos WHERE id = ?");
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado.', 404);
// ── Consentimientos ───────────────────────────────────────────
$stmt = $pdo->prepare(
"SELECT tc.id,
tc.turno_id,
tc.formulario_id,
tc.token,
tc.estado,
tc.enviado_at,
tc.firmado_at,
f.nombre AS formulario_nombre
FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ?
ORDER BY tc.id ASC"
);
$stmt->execute([$turnoId]);
$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC);
$respuesta = [
'turno_id' => $turnoId,
'turno_estado' => $turno['estado'],
'consentimientos' => $consentimientos,
];
// ── Datos adicionales si solicita solicitud ───────────────────
if ($incluirSolicitud) {
$stmt = $pdo->prepare(
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id,
s.total_cobrado, s.metodo_pago, s.observaciones, s.creado_at,
l.nombre AS lugar_nombre
FROM turnero_solicitudes s
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
WHERE s.turno_id = ?
LIMIT 1"
);
$stmt->execute([$turnoId]);
$solicitud = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$paciente = null;
$examenes = [];
if ($solicitud) {
// Paciente
$stmt = $pdo->prepare(
"SELECT id, full_name, tipo_documento, documento,
fecha_nacimiento, telefono, celular
FROM lab_pacientes
WHERE id = ?"
);
$stmt->execute([$solicitud['paciente_id']]);
$paciente = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
// Exámenes
$stmt = $pdo->prepare(
"SELECT et.id, et.codigo, et.nombre, et.categoria
FROM turnero_examen_items tei
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE tei.solicitud_id = ?
ORDER BY et.categoria, et.nombre"
);
$stmt->execute([$solicitud['id']]);
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$respuesta['solicitud'] = $solicitud;
$respuesta['paciente'] = $paciente;
$respuesta['examenes'] = $examenes;
}
jsonOk($respuesta);
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* modules/turnero/api/get_dashboard.php
* GET ?fecha=YYYY-MM-DD (por defecto = hoy)
*
* Devuelve métricas del día para el dashboard del turnero:
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad
* - desglose por lugar
* - lista de turnos del día (para tabla detalle)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// ── 1. Sesión del día ─────────────────────────────────────────
$stmtSesion = $pdo->prepare(
'SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1'
);
$stmtSesion->execute([$fecha]);
$sesion = $stmtSesion->fetch(PDO::FETCH_ASSOC);
if (!$sesion) {
jsonOk([
'fecha' => $fecha,
'sesion' => null,
'resumen' => ['total' => 0, 'atendidos' => 0, 'en_espera' => 0,
'ausentes' => 0, 'cancelados' => 0, 'tiempo_promedio_min' => null],
'por_prioridad' => [],
'por_lugar' => [],
'turnos' => [],
]);
}
$sesionId = (int)$sesion['id'];
// ── 2. Resumen global ─────────────────────────────────────────
$stmtRes = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(estado = 'ausente') AS ausentes,
SUM(estado = 'cancelado') AS cancelados,
ROUND(
AVG(
CASE
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
END
), 1
) AS tiempo_espera_promedio_min,
ROUND(
AVG(
CASE
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
END
), 1
) AS tiempo_servicio_promedio_min
FROM turnero_turnos
WHERE sesion_id = ?"
);
$stmtRes->execute([$sesionId]);
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
// ── 3. Desglose por prioridad ─────────────────────────────────
$stmtPri = $pdo->prepare(
"SELECT p.codigo, p.nombre, p.color,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS pendientes,
SUM(t.estado = 'ausente') AS ausentes
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
GROUP BY p.id
ORDER BY p.orden_peso ASC"
);
$stmtPri->execute([$sesionId]);
$porPrioridad = $stmtPri->fetchAll(PDO::FETCH_ASSOC);
// ── 4. Desglose por lugar ─────────────────────────────────────
$stmtLug = $pdo->prepare(
"SELECT l.nombre AS lugar,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
ROUND(AVG(
CASE
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
END
), 1) AS tiempo_servicio_promedio_min
FROM turnero_turnos t
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
WHERE t.sesion_id = ?
GROUP BY l.id
ORDER BY l.sort_order ASC, l.nombre ASC"
);
$stmtLug->execute([$sesionId]);
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
// ── 5. Lista de turnos del día ────────────────────────────────
$stmtTurnos = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado,
t.paciente_nombre, t.paciente_cel,
t.creado_at, t.inicio_recepcion_at, t.fin_recepcion_at,
t.inicio_lugar_at, t.fin_lugar_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color,
l.nombre AS lugar_nombre,
s.nombre_completo AS paciente_bd,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
LEFT JOIN lab_pacientes s ON s.id = ts.paciente_id
WHERE t.sesion_id = ?
ORDER BY t.numero ASC"
);
$stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// ── 6. Consentimientos del día ────────────────────────────────
$stmtcons = $pdo->prepare(
"SELECT tc.estado, COUNT(*) AS cnt
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
WHERE t.sesion_id = ?
GROUP BY tc.estado"
);
$stmtcons->execute([$sesionId]);
$consentStats = [];
foreach ($stmtcons->fetchAll(PDO::FETCH_ASSOC) as $r) {
$consentStats[$r['estado']] = (int)$r['cnt'];
}
jsonOk([
'fecha' => $fecha,
'sesion' => [
'id' => $sesionId,
'inicio' => $sesion['inicio_at'] ? date('H:i', strtotime($sesion['inicio_at'])) : null,
'fin' => $sesion['fin_at'] ? date('H:i', strtotime($sesion['fin_at'])) : null,
'abierta' => $sesion['fin_at'] === null,
],
'resumen' => $resumen,
'por_prioridad' => $porPrioridad,
'por_lugar' => $porLugar,
'consent_stats' => $consentStats,
'turnos' => $turnos,
]);
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* POST /modules/turnero/api/llamar_turno.php
* Llama el siguiente turno de una cola con el motor de prioridades.
* Requiere login + módulo turnero.
*
* Body JSON:
* area string requerido "recepcion" | "lugar"
* lugar_id int requerido si area = "lugar"
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$area = $datos['area'] ?? '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
// ── Validación ────────────────────────────────────────────────
if (!in_array($area, ['recepcion', 'lugar'], true)) {
jsonError('area debe ser "recepcion" o "lugar".');
}
if ($area === 'lugar' && !$lugarId) {
jsonError('lugar_id es obligatorio cuando area = "lugar".');
}
$pdo = db();
$pdo->beginTransaction();
try {
if ($area === 'recepcion') {
// Cola recepción: turnos en estado 'espera' sin filtro de lugar
$turno = siguienteTurnoEnCola('espera');
if (!$turno) {
$pdo->rollBack();
jsonOk(['turno' => null], 'Cola de recepción vacía');
}
// Actualizar estado + timestamp
$stmt = $pdo->prepare(
'UPDATE turnero_turnos
SET estado = "en_recepcion",
llamado_recepcion_at = NOW(),
inicio_recepcion_at = NOW(),
atendido_recepcion_por = ?
WHERE id = ? AND estado = "espera"'
);
$stmt->execute([adminId(), $turno['id']]);
if ($stmt->rowCount() === 0) {
// Otro proceso lo tomó primero (race condition)
$pdo->rollBack();
jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409);
}
} else {
// Cola de un Lugar específico: turnos 'en_espera_lugar' asignados a ese lugar
$turno = siguienteTurnoEnCola('en_espera_lugar', $lugarId);
if (!$turno) {
$pdo->rollBack();
jsonOk(['turno' => null], 'Cola del lugar vacía');
}
$stmt = $pdo->prepare(
'UPDATE turnero_turnos
SET estado = "en_servicio",
llamado_lugar_at = NOW(),
inicio_lugar_at = NOW(),
atendido_lugar_por = ?
WHERE id = ? AND estado = "en_espera_lugar"'
);
$stmt->execute([adminId(), $turno['id']]);
if ($stmt->rowCount() === 0) {
$pdo->rollBack();
jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409);
}
}
// Refrescar datos del turno actualizado
$stmt = $pdo->prepare(
'SELECT t.*,
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.id = ?'
);
$stmt->execute([$turno['id']]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
$pdo->commit();
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonError('Error al llamar turno: ' . $e->getMessage(), 500);
}
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* modules/turnero/api/save_ex_tipo.php
* GET ?id=X → devuelve datos de un tipo de examen (incluyendo formulario_id vinculado)
* POST {codigo, nombre, categoria, formulario_id, activo} → crear
* POST {id, codigo, nombre, categoria, formulario_id, activo} → actualizar
* POST {id, _delete: true} → eliminar
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
// ── GET: devolver datos de un tipo de examen ─────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$stmt = db()->prepare(
'SELECT et.*,
(SELECT formulario_id FROM exam_tipo_consentimientos
WHERE exam_tipo_id = et.id LIMIT 1) AS formulario_id
FROM exam_tipos et
WHERE et.id = ?'
);
$stmt->execute([$id]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$data) jsonError('Examen no encontrado', 404);
jsonOk(['data' => $data]);
}
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$delete = !empty($input['_delete']);
// ── ELIMINAR ─────────────────────────────────────────────────
if ($delete) {
if (!$id) jsonError('ID requerido');
// Bloquear si tiene ítems en solicitudes activas
$stmt = db()->prepare(
'SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?'
);
$stmt->execute([$id]);
if ($stmt->fetchColumn() > 0) {
jsonError('No se puede eliminar: hay solicitudes que incluyen este examen. Desactívelo en su lugar.');
}
// Eliminar relación de consentimiento primero (FK)
db()->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]);
db()->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]);
jsonOk([], 'Examen eliminado');
}
// ── CREAR / ACTUALIZAR ───────────────────────────────────────
$codigo = strtoupper(trim($input['codigo'] ?? ''));
$nombre = trim($input['nombre'] ?? '');
$categoria = trim($input['categoria'] ?? '');
$formId = (int)($input['formulario_id'] ?? 0) ?: null;
$activo = (int)($input['activo'] ?? 1);
if ($codigo === '') jsonError('El código es requerido');
if ($nombre === '') jsonError('El nombre es requerido');
if (strlen($codigo) > 20) jsonError('El código no puede tener más de 20 caracteres');
$pdo = db();
$pdo->beginTransaction();
try {
if ($id) {
$stmt = $pdo->prepare(
'UPDATE exam_tipos SET codigo=?, nombre=?, categoria=?, activo=? WHERE id=?'
);
$stmt->execute([$codigo, $nombre, $categoria ?: null, $activo, $id]);
$examId = $id;
} else {
$stmt = $pdo->prepare(
'INSERT INTO exam_tipos (codigo, nombre, categoria, activo) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$codigo, $nombre, $categoria ?: null, $activo]);
$examId = (int)$pdo->lastInsertId();
}
// Actualizar relación formulario ↔ examen
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]);
if ($formId) {
$pdo->prepare(
'INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?, ?)'
)->execute([$examId, $formId]);
}
$pdo->commit();
jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado');
} catch (\Throwable $e) {
$pdo->rollBack();
// Código SQLSTATE 23000 = violación de unicidad
if ($e->getCode() === '23000') {
jsonError('Ya existe un examen con ese código');
}
jsonError('Error interno: ' . $e->getMessage(), 500);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* modules/turnero/api/save_lugar.php
* GET ?id=X → devuelve datos de un lugar
* POST {nombre, descripcion, sort_order, activo} → crear
* POST {id, nombre, descripcion, sort_order, activo} → actualizar
* POST {id, _delete: true} → eliminar
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
// ── GET: devolver datos de un lugar ──────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$row = db()->prepare('SELECT * FROM turnero_lugares WHERE id = ?');
$row->execute([$id]);
$data = $row->fetch(PDO::FETCH_ASSOC);
if (!$data) jsonError('Lugar no encontrado', 404);
jsonOk(['data' => $data]);
}
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$delete = !empty($input['_delete']);
// ── ELIMINAR ─────────────────────────────────────────────────
if ($delete) {
if (!$id) jsonError('ID requerido');
// Bloquear si tiene turnos asignados
$stmt = db()->prepare('SELECT COUNT(*) FROM turnero_turnos WHERE lugar_destino_id = ?');
$stmt->execute([$id]);
if ($stmt->fetchColumn() > 0) {
jsonError('No se puede eliminar: tiene turnos asignados. Desactívelo en su lugar.');
}
$stmt = db()->prepare('DELETE FROM turnero_lugares WHERE id = ?');
$stmt->execute([$id]);
jsonOk([], 'Lugar eliminado');
}
// ── CREAR / ACTUALIZAR ───────────────────────────────────────
$nombre = trim($input['nombre'] ?? '');
$desc = trim($input['descripcion'] ?? '');
$sortOrder = (int)($input['sort_order'] ?? 99);
$activo = (int)($input['activo'] ?? 1);
if ($nombre === '') jsonError('El nombre es requerido');
if ($sortOrder < 1) jsonError('El orden debe ser mayor a 0');
if ($id) {
$stmt = db()->prepare(
'UPDATE turnero_lugares SET nombre=?, descripcion=?, sort_order=?, activo=? WHERE id=?'
);
$stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo, $id]);
jsonOk(['id' => $id], 'Lugar actualizado');
} else {
$stmt = db()->prepare(
'INSERT INTO turnero_lugares (nombre, descripcion, sort_order, activo) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo]);
jsonOk(['id' => (int)db()->lastInsertId()], 'Lugar creado');
}
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* modules/turnero/api/save_prioridad.php
* POST {id, nombre, color, orden_peso, activo} → actualizar prioridad existente
* (Las prioridades no se crean ni eliminan vía API — se gestionan en la migración)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$nombre = trim($input['nombre'] ?? '');
$color = trim($input['color'] ?? '#6b7280');
$ordenPeso = (int)($input['orden_peso'] ?? 1);
$activo = (int)($input['activo'] ?? 1);
if (!$id) jsonError('ID requerido');
if ($nombre === '') jsonError('El nombre es requerido');
if ($ordenPeso < 1) jsonError('El orden debe ser mayor a 0');
if (!preg_match('/^#[0-9a-f]{6}$/i', $color)) jsonError('Color hexadecimal inválido (formato: #RRGGBB)');
$stmt = db()->prepare(
'UPDATE turnero_prioridades SET nombre=?, color=?, orden_peso=?, activo=? WHERE id=?'
);
$stmt->execute([$nombre, $color, $ordenPeso, $activo, $id]);
if ($stmt->rowCount() === 0) {
jsonError('Prioridad no encontrada', 404);
}
jsonOk(['id' => $id], 'Prioridad actualizada');
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* POST /modules/turnero/api/send_consentimiento.php
* Genera y/o reenvía consentimientos informados por WhatsApp para un turno.
*
* Lógica:
* 1. Lee los turnero_examen_items del turno.
* 2. Para cada exam_tipo_id busca en exam_tipo_consentimientos qué formulario_id requiere.
* 3. Deduplica: varios exámenes que apunten al mismo formulario → 1 solo envío.
* 4. Por cada formulario distinto:
* a. Si ya existe un turnero_consentimientos con ese (turno_id, formulario_id) → reutiliza / reenvía.
* b. Si no existe: genera token UUID, inserta con estado 'pendiente'.
* 5. Envía mensaje WhatsApp al celular del turno / paciente con enlace de firma.
* 6. Actualiza estado → 'enviado' y registra enviado_at.
*
* Body JSON:
* turno_id int requerido
*
* Requiere login + módulo turnero.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
require_once __DIR__ . '/../../../services/WhatsAppService.php';
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── 1. Cargar turno y su solicitud ────────────────────────────
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel,
t.estado,
s.id AS solicitud_id,
s.paciente_id,
p.telefono AS pac_telefono,
p.celular AS pac_celular,
p.full_name AS pac_nombre
FROM turnero_turnos t
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
WHERE t.id = ?"
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado.', 404);
if (!$turno['solicitud_id']) jsonError('El turno no tiene solicitud registrada. Guarde la solicitud primero.', 422);
// Determinar celular de contacto (preferencia: paciente → kiosko)
$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null;
if (!$celular) {
jsonError('El paciente no tiene número de celular registrado. Actualice el paciente antes de enviar.', 422);
}
// Normalizar celular (quitar espacios / guiones, agregar +57 si no tiene código)
$celular = preg_replace('/[\s\-\.]/', '', $celular);
if (!str_starts_with($celular, '+')) {
// Asumir Colombia si no tiene prefijo internacional
$celular = '+57' . ltrim($celular, '0');
}
// ── 2. Obtener exámenes de la solicitud ───────────────────────
$stmt = $pdo->prepare(
"SELECT exam_tipo_id FROM turnero_examen_items WHERE solicitud_id = ?"
);
$stmt->execute([$turno['solicitud_id']]);
$examIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($examIds)) {
jsonError('La solicitud no tiene exámenes registrados.', 422);
}
// ── 3. Obtener formularios de consentimiento (deduplicados) ───
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre
FROM exam_tipo_consentimientos etc
JOIN lab_formularios f ON f.id = etc.formulario_id
WHERE etc.exam_tipo_id IN ({$in})"
);
$stmt->execute($examIds);
$formulariosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($formulariosRequeridos)) {
jsonOk(['consentimientos' => [], 'mensaje' => 'Ningún examen requiere consentimiento.']);
}
// ── 4. Generar / recuperar tokens UUID ────────────────────────
function generarUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
$pdo->beginTransaction();
$consentimientosResultado = [];
$erroresEnvio = [];
try {
$stmtBuscar = $pdo->prepare(
"SELECT id, token, estado FROM turnero_consentimientos
WHERE turno_id = ? AND formulario_id = ?"
);
$stmtInsertar = $pdo->prepare(
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
VALUES (?, ?, ?, 'pendiente')"
);
$stmtActualizar = $pdo->prepare(
"UPDATE turnero_consentimientos
SET estado = 'enviado', enviado_at = NOW()
WHERE id = ?"
);
$wa = new WhatsAppService();
// URL base del sistema (para generar el enlace de firma)
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
// Si BASE_URL está definida, úsala
if (defined('BASE_URL')) {
$baseUrl = rtrim(BASE_URL, '/');
}
foreach ($formulariosRequeridos as $form) {
$formularioId = (int) $form['formulario_id'];
$formularioNom = $form['formulario_nombre'];
// Buscar si ya existe
$stmtBuscar->execute([$turnoId, $formularioId]);
$existente = $stmtBuscar->fetch(PDO::FETCH_ASSOC);
if ($existente) {
$consentId = (int) $existente['id'];
$token = $existente['token'];
// Si ya está firmado/rechazado, no reenviar
if (in_array($existente['estado'], ['firmado', 'rechazado'], true)) {
$consentimientosResultado[] = [
'id' => $consentId,
'formulario_id' => $formularioId,
'formulario_nombre'=> $formularioNom,
'estado' => $existente['estado'],
'enviado' => false,
];
continue;
}
} else {
$token = generarUuid();
$stmtInsertar->execute([$turnoId, $formularioId, $token]);
$consentId = (int) $pdo->lastInsertId();
}
// ── Construir enlace de firma ──────────────────────────
$enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token);
// ── Nombre del paciente para el mensaje ───────────────
$nombrePac = $turno['pac_nombre'] ?? $turno['paciente_nombre'] ?? 'Paciente';
// ── Enviar mensaje WhatsApp ────────────────────────────
// Intentamos enviar con template "consentimiento_turno".
// Si el template no existe, enviamos texto plano como fallback.
$enviado = false;
try {
$wa->sendTemplateMessage(
$celular,
'consentimiento_turno',
'es',
// Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno
[
htmlspecialchars($nombrePac, ENT_QUOTES),
htmlspecialchars($formularioNom, ENT_QUOTES),
$turno['codigo'],
],
// Parámetro del header o botón URL (URL del enlace)
[$enlaceFirma]
);
$enviado = true;
} catch (\Throwable $eTemplate) {
// Fallback: enviar mensaje de texto plano
try {
$mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* "
. "debe firmar el siguiente consentimiento informado:\n\n"
. "*{$formularioNom}*\n\n"
. "Puede firmarlo en el siguiente enlace:\n{$enlaceFirma}\n\n"
. "Si ya firmó este documento presencial, ignore este mensaje.";
$wa->sendTextMessage($celular, $mensajeTexto);
$enviado = true;
} catch (\Throwable $eTexto) {
$erroresEnvio[] = "Formulario '{$formularioNom}': " . $eTexto->getMessage();
}
}
if ($enviado) {
$stmtActualizar->execute([$consentId]);
$estado = 'enviado';
} else {
$estado = 'pendiente';
}
$consentimientosResultado[] = [
'id' => $consentId,
'formulario_id' => $formularioId,
'formulario_nombre' => $formularioNom,
'token' => $token,
'estado' => $estado,
'enviado' => $enviado,
'enlace_firma' => $enlaceFirma,
];
}
$pdo->commit();
$msgExtra = !empty($erroresEnvio)
? ' (Advertencias: ' . implode('; ', $erroresEnvio) . ')'
: '';
jsonOk(
['consentimientos' => $consentimientosResultado],
count(array_filter($consentimientosResultado, fn($c) => $c['enviado']))
. ' consentimiento(s) enviado(s) por WhatsApp' . $msgExtra
);
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
jsonError('Error al procesar consentimientos: ' . $e->getMessage(), 500);
}
+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* modules/turnero/api/sesion_turno.php
*
* POST {accion: 'abrir'} → Crear/abrir sesión de hoy
* POST {accion: 'cerrar', sesion_id: X} → Cerrar sesión activa
* POST {accion: 'reabrir', sesion_id: X} → Reabrir sesión (borra fin_at)
* POST {accion: 'config_wa', template, lang} → Guardar config WhatsApp turnero
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$accion = trim($input['accion'] ?? '');
if (!in_array($accion, ['abrir', 'cerrar', 'reabrir', 'config_wa'], true)) {
jsonError('Acción no válida');
}
$pdo = db();
$uid = adminId();
// ── ABRIR sesión del día ─────────────────────────────────────
if ($accion === 'abrir') {
$hoy = date('Y-m-d');
// Verificar si ya existe
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
$stmt->execute([$hoy]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
if ($existing['fin_at'] === null) {
jsonError('Ya hay una sesión abierta para hoy');
}
// Re-abrir (fue cerrada)
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?')
->execute([$existing['id']]);
jsonOk(['sesion_id' => $existing['id']], 'Sesión del día reabierta');
}
$stmt = $pdo->prepare(
'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$hoy, $uid]);
jsonOk(['sesion_id' => (int)$pdo->lastInsertId()], 'Sesión del día abierta');
}
// ── CERRAR sesión ────────────────────────────────────────────
if ($accion === 'cerrar') {
$sesionId = (int)($input['sesion_id'] ?? 0);
if (!$sesionId) jsonError('sesion_id requerido');
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$s = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$s) jsonError('Sesión no encontrada', 404);
if ($s['fin_at']) jsonError('La sesión ya está cerrada');
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NOW(), cerrado_por=? WHERE id=?')
->execute([$uid, $sesionId]);
jsonOk(['sesion_id' => $sesionId], 'Sesión cerrada correctamente');
}
// ── REABRIR sesión ───────────────────────────────────────────
if ($accion === 'reabrir') {
$sesionId = (int)($input['sesion_id'] ?? 0);
if (!$sesionId) jsonError('sesion_id requerido');
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$s = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$s) jsonError('Sesión no encontrada', 404);
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?')
->execute([$sesionId]);
jsonOk(['sesion_id' => $sesionId], 'Sesión reabierta');
}
// ── CONFIG WHATSAPP ──────────────────────────────────────────
if ($accion === 'config_wa') {
$template = trim($input['template'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
if ($template === '') jsonError('El nombre de plantilla es requerido');
if (!preg_match('/^[a-z0-9_]{1,64}$/i', $template)) {
jsonError('Nombre de plantilla inválido — solo letras, números y guiones bajos');
}
if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/i', $lang)) {
jsonError('Código de idioma inválido (formato: es_CO)');
}
// Upsert en lab_config
$upsert = $pdo->prepare(
'INSERT INTO lab_config (clave, valor) VALUES (?, ?)
ON DUPLICATE KEY UPDATE valor = VALUES(valor)'
);
$upsert->execute(['turnero_wa_template', $template]);
$upsert->execute(['turnero_wa_lang', $lang]);
jsonOk([], 'Configuración WhatsApp guardada');
}
+153
View File
@@ -0,0 +1,153 @@
<?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');
// ── 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();
$lastPing = null; // sse_ping_at de la última iteración
$lastKeepalive = time();
$lastEmit = 0;
// Emitir estado inicial inmediatamente
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
sseEvent('cola_update', $snapshot);
while (!connection_aborted()) {
sleep(2);
// 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;
}
}