diff --git a/migrations/20260823_tv_voz_log.sql b/migrations/20260823_tv_voz_log.sql new file mode 100644 index 0000000..57fc94b --- /dev/null +++ b/migrations/20260823_tv_voz_log.sql @@ -0,0 +1,20 @@ +-- 20260823_tv_voz_log.sql +-- +-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no +-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla: +-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si +-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten +-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó. +-- +-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla. + +CREATE TABLE IF NOT EXISTS turnero_tv_log ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo', + detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración', + ip VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_evento_fecha (evento, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/modules/turnero/api/log_tv.php b/modules/turnero/api/log_tv.php new file mode 100644 index 0000000..37a84e7 --- /dev/null +++ b/modules/turnero/api/log_tv.php @@ -0,0 +1,48 @@ + 2000) exit; + +$d = json_decode($raw, true); +if (!is_array($d)) exit; + +$evento = $d['e'] ?? ''; +$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo']; +if (!in_array($evento, $PERMITIDOS, true)) exit; + +unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente + +try { + $pdo = Database::getInstance()->getConnection(); + $stmt = $pdo->prepare( + "INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)" + ); + $stmt->execute([ + $evento, + mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500), + substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45), + substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255), + ]); +} catch (\Throwable $e) { + // La bitácora jamás debe afectar a la pantalla + error_log('[log_tv] ' . $e->getMessage()); +} diff --git a/modules/turnero/views/display_global.php b/modules/turnero/views/display_global.php index 9404d90..266edd6 100644 --- a/modules/turnero/views/display_global.php +++ b/modules/turnero/views/display_global.php @@ -651,6 +651,35 @@ if ('speechSynthesis' in window) { // A nivel de archivo porque showAnnouncement() también lo necesita. const BEEP_MS = 700; +// ── Anclaje contra el recolector de basura ── +// Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance, +// el recolector puede llevársela A MITAD DE FRASE. El audio se corta en seco y +// 'end' no llega nunca. Como depende de cuándo pase el recolector, ocurre "a +// veces" y nunca se reproduce a voluntad. Cada locución queda anclada aquí +// hasta que termina. +const _uttAncladas = new Set(); + +// ── Bitácora de la voz ── +// Los fallos intermitentes no se pueden depurar mirando la pantalla: hay que +// registrarlos cuando ocurren. Cada evento va a un anillo local (últimos 200, +// consultable con localStorage.tvVozLog en la consola del televisor) y al +// servidor con sendBeacon, que no bloquea ni exige respuesta. +function vozLog(evento, datos) { + const fila = Object.assign({ t: new Date().toISOString().slice(0, 19), e: evento }, datos || {}); + try { + const ring = JSON.parse(localStorage.getItem('tvVozLog') || '[]'); + ring.push(fila); + while (ring.length > 200) ring.shift(); + localStorage.setItem('tvVozLog', JSON.stringify(ring)); + } catch (_) {} + try { + navigator.sendBeacon( + 'modules/turnero/api/log_tv.php', + new Blob([JSON.stringify(fila)], { type: 'application/json' }) + ); + } catch (_) {} +} + /** * Dice el llamado en voz alta. * @@ -685,10 +714,28 @@ function anunciarTurno(codigo, destino, paciente, onFin) { utt.lang = 'es-CO'; utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1; + _uttAncladas.add(utt); + const soltar = () => _uttAncladas.delete(utt); + const pedido = Date.now(); + let arranco = false; - utt.addEventListener('start', () => { arranco = true; }); - utt.addEventListener('end', avisar); - utt.addEventListener('error', () => esReintento ? avisar() : decir(true)); + utt.addEventListener('start', () => { + arranco = true; + vozLog('start', { cod: codigo, ms: Date.now() - pedido, + voz: utt.voice ? utt.voice.name : '(navegador)', + local: utt.voice ? !!utt.voice.localService : null, + re: esReintento ? 1 : 0 }); + }); + utt.addEventListener('end', () => { + soltar(); + vozLog('end', { cod: codigo, dur: Date.now() - pedido }); + avisar(); + }); + utt.addEventListener('error', (ev) => { + soltar(); + vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 }); + esReintento ? avisar() : decir(true); + }); // Que speak() no lance nada no significa que vaya a sonar: si el // navegador bloquea el audio, o la voz no sirve, no pasa absolutamente @@ -701,7 +748,12 @@ function anunciarTurno(codigo, destino, paciente, onFin) { // cancelarla aquí era cortarle la frase y repetirla con otra voz. // De ahí que a veces se oyera media frase y luego otra distinta. if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return; - if (esReintento) { avisar(); return; } + soltar(); + if (esReintento) { + vozLog('mudo', { cod: codigo }); // ni la voz del navegador arrancó + avisar(); return; + } + vozLog('sin_start', { cod: codigo }); // la elegida nunca arrancó: reintento window.speechSynthesis.cancel(); decir(true); // segunda oportunidad, con la voz del navegador }, margen); @@ -721,6 +773,10 @@ function anunciarTurno(codigo, destino, paciente, onFin) { const voz = getVozES(); if (voz) utt.voice = voz; } + // Tras un cancel(), en Windows y ChromeOS el motor puede quedar en + // pausa: speak() encola y jamás suena, sin error alguno. resume() + // sobre un motor sano no hace nada, así que se llama siempre. + window.speechSynthesis.resume(); window.speechSynthesis.speak(utt); }, esReintento ? 0 : BEEP_MS); }; diff --git a/modules/turnero/views/recepcion.php b/modules/turnero/views/recepcion.php index de911c3..92bc69d 100644 --- a/modules/turnero/views/recepcion.php +++ b/modules/turnero/views/recepcion.php @@ -1553,7 +1553,7 @@ function abrirFicha(turno) { .catch(() => {}); } else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) { // El kiosko capturó una cédula como nombre — consultar RIPS de inmediato - consultarExamenesRips(turno.paciente_nombre.trim()); + iniciarSondeoRips(turno.paciente_nombre.trim()); } // Cargar consentimientos del desk actual para este turno @@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) { // Consultar exámenes recientes en RIPS (últimos 5 min) const cedula = (pac.numero_documento || pac.documento || '').toString().trim(); - if (cedula) consultarExamenesRips(cedula); + if (cedula) iniciarSondeoRips(cedula); filtrarExamenesPorGenero(); } @@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() { // ── Exámenes desde RIPS ─────────────────────────────────────── let _ripsData = null; +// ── Sondeo RIPS ── +// La facturación en el sistema del laboratorio ocurre MIENTRAS la +// recepcionista atiende, así que consultar una sola vez al vincular al +// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS +// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso +// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10 +// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno. +let _ripsSondeoId = null; +let _ripsSondeoQuedan = 0; + +function iniciarSondeoRips(cedula) { + detenerSondeoRips(); + if (!cedula) return; + _ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min + consultarExamenesRips(cedula); // el primero, de inmediato + _ripsSondeoId = setInterval(() => { + if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; } + consultarExamenesRips(cedula); + }, 10000); +} + +function detenerSondeoRips() { + if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; } +} + async function consultarExamenesRips(cedula) { _ripsData = null; document.getElementById('banner-rips').classList.add('d-none'); @@ -1715,7 +1740,9 @@ async function consultarExamenesRips(cedula) { } // Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan // automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación. - if (d.fuente === 'cache') { + detenerSondeoRips(); + const yaHayManuales = examTS && examTS.items && examTS.items.length > 0; + if (d.fuente === 'cache' && !yaHayManuales) { await cargarExamenesRips(); } else { document.getElementById('banner-rips-txt').textContent = @@ -1760,6 +1787,7 @@ async function cargarExamenesRips() { } function descartarRips() { + detenerSondeoRips(); _ripsData = null; document.getElementById('banner-rips').classList.add('d-none'); } diff --git a/scripts/test_rips_sondeo.js b/scripts/test_rips_sondeo.js new file mode 100644 index 0000000..5781c81 --- /dev/null +++ b/scripts/test_rips_sondeo.js @@ -0,0 +1,103 @@ +/** + * scripts/test_rips_sondeo.js — node scripts/test_rips_sondeo.js + * + * Pruebas del sondeo RIPS contra el código real de recepcion.php. + * + * El defecto que esto vigila: la consulta a RIPS se hacía UNA vez al vincular + * al paciente, y en 7 días 547 de 549 registros llegaron después de esa única + * consulta. El sondeo reintenta cada 10 s hasta 10 minutos y debe detenerse + * al encontrar, al limpiar la ficha, o al agotar los intentos. + */ +const fs = require('fs'); +let fallos = 0; +const ok = (d, c) => { console.log((c ? ' ok ' : ' FALLA ') + d); if (!c) fallos++; }; + +const src = fs.readFileSync(__dirname + '/../modules/turnero/views/recepcion.php', 'utf8') + .replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"'); + +// Se extraen exactamente las tres piezas del control de sondeo +const m = src.match(/let _ripsSondeoId[\s\S]*?function detenerSondeoRips\(\) \{[\s\S]*?\n\}/); +if (!m) { console.log(' FALLA no se encontró el bloque de sondeo'); process.exit(1); } + +function armar() { + let consultas = 0; + const intervalos = new Map(); + let proximoId = 1; + const setIntervalFake = (fn, ms) => { const id = proximoId++; intervalos.set(id, { fn, ms }); return id; }; + const clearIntervalFake = (id) => intervalos.delete(id); + + const api = new Function( + 'setInterval', 'clearInterval', 'INTERVALOS', 'CONTADOR', + 'let _ripsData = null;' + + 'const consultarExamenesRips = (c) => { CONTADOR.n++; };' + + m[0] + `; + return { + iniciar: iniciarSondeoRips, + detener: detenerSondeoRips, + tic() { for (const v of [...INTERVALOS.values()]) v.fn(); }, + activo() { return _ripsSondeoId !== null; }, + encontrado() { _ripsData = { encontrados: [1] }; }, + };` + )(setIntervalFake, clearIntervalFake, intervalos, (globalThis.__c = { n: 0 })); + return { api, contador: globalThis.__c, intervalos }; +} + +// ── 1. Consulta de inmediato y reintenta ──────────────────────────────── +{ + const { api, contador } = armar(); + api.iniciar('12345678'); + ok('consulta de inmediato al vincular', contador.n === 1); + api.tic(); api.tic(); api.tic(); + ok('reintenta en cada ciclo mientras no encuentra', contador.n === 4); +} + +// ── 2. Al encontrar, deja de sondear ──────────────────────────────────── +{ + const { api, contador } = armar(); + api.iniciar('12345678'); + api.tic(); + api.encontrado(); + api.tic(); // este ciclo detecta _ripsData y se apaga + const n = contador.n; + api.tic(); api.tic(); + ok('al encontrar los exámenes se apaga y no consulta más', + contador.n === n && !api.activo()); +} + +// ── 3. Limpiar la ficha lo detiene ────────────────────────────────────── +{ + const { api, contador } = armar(); + api.iniciar('12345678'); + api.detener(); + const n = contador.n; + api.tic(); api.tic(); + ok('limpiar la ficha detiene el sondeo en seco', contador.n === n && !api.activo()); +} + +// ── 4. Tope de intentos: no sondea para siempre ───────────────────────── +{ + const { api, contador } = armar(); + api.iniciar('12345678'); + for (let i = 0; i < 80; i++) api.tic(); + ok('se rinde tras ~60 intentos (10 min), no queda sondeando eternamente', + contador.n <= 61 && !api.activo()); +} + +// ── 5. Vincular a otro paciente reinicia sin duplicar intervalos ──────── +{ + const { api, contador, intervalos } = armar(); + api.iniciar('11111111'); + api.iniciar('22222222'); + ok('re-vincular no deja dos sondeos corriendo a la vez', intervalos.size === 1); +} + +// ── 6. Sin cédula no arranca ──────────────────────────────────────────── +{ + const { api, contador } = armar(); + api.iniciar(''); + ok('sin cédula no consulta ni deja intervalos', contador.n === 0 && !api.activo()); +} + +console.log(); +console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.'); +process.exit(fallos === 0 ? 0 : 1); diff --git a/scripts/test_rips_ventana.php b/scripts/test_rips_ventana.php new file mode 100644 index 0000000..75348b8 --- /dev/null +++ b/scripts/test_rips_ventana.php @@ -0,0 +1,84 @@ +getConnection(); + +$pdo->exec("CREATE TEMPORARY TABLE tmp_rips LIKE rips_examenes_pendientes"); + +$ins = $pdo->prepare( + "INSERT INTO tmp_rips (numero_documento, datos, recepcion_id, hora_recepcion, created_at, turno_id) + VALUES (?, ?, ?, ?, ?, ?)" +); +// (documento, minutos hacia atrás de created_at, turno_id) +$casos = [ + ['1111', 5, null], // reciente y libre → debe aparecer + ['2222', 45, null], // fuera de la ventana de 30 min + ['3333', 5, 999], // ya usado por un turno + ['4444', 1500, null], // de ayer + ['5555', 20, null], // mismo doc, dos registros… + ['5555', 3, null], // …gana el más reciente (marcado v2) +]; +foreach ($casos as $i => [$doc, $min, $turno]) { + $ins->execute([ + $doc, + json_encode([['cod_examen' => 'T' . $i, 'v' => $i === 5 ? 'v2' : 'v1']]), + 1000 + $i, + '08:00:00', + date('Y-m-d H:i:s', time() - $min * 60), + $turno, + ]); +} + +// La MISMA consulta de get_examenes_rips.php, apuntada a la tabla temporal +$q = $pdo->prepare( + "SELECT datos FROM tmp_rips + WHERE numero_documento = ? + AND DATE(created_at) = CURDATE() + AND created_at >= NOW() - INTERVAL 30 MINUTE + AND turno_id IS NULL + ORDER BY created_at DESC + LIMIT 1" +); +$buscar = function (string $doc) use ($q) { + $q->execute([$doc]); + $r = $q->fetch(PDO::FETCH_ASSOC); + return $r ? json_decode($r['datos'], true) : null; +}; + +ok('un registro de hace 5 minutos se encuentra', $buscar('1111') !== null); +ok('uno de hace 45 minutos queda fuera de la ventana', $buscar('2222') === null); +ok('uno ya asignado a un turno no se reutiliza', $buscar('3333') === null); +ok('uno de ayer no aparece aunque el documento coincida', $buscar('4444') === null); +$r = $buscar('5555'); +ok('con dos del mismo documento gana el más reciente', ($r[0]['v'] ?? '') === 'v2'); + +// El re-empuje del scheduler (DELETE + INSERT) refresca created_at, así que un +// registro viejo que el sistema legado reenvía vuelve a entrar en la ventana. +$pdo->exec("DELETE FROM tmp_rips WHERE numero_documento = '2222'"); +$ins->execute(['2222', json_encode([['cod_examen' => 'RE']]), 2000, '08:00:00', + date('Y-m-d H:i:s'), null]); +ok('un re-empuje del scheduler vuelve a hacer visible el registro', $buscar('2222') !== null); + +echo "\n" . ($fallos === 0 ? "Todo correcto.\n" : "$fallos pruebas fallaron.\n"); +exit($fallos === 0 ? 0 : 1); diff --git a/scripts/test_tv_voz.js b/scripts/test_tv_voz.js new file mode 100644 index 0000000..099dedb --- /dev/null +++ b/scripts/test_tv_voz.js @@ -0,0 +1,136 @@ +/** + * scripts/test_tv_voz.js — node scripts/test_tv_voz.js + * + * Pruebas de la voz del televisor CONTRA EL CÓDIGO REAL: se extrae el bloque + * de anuncios de display_global.php y se ejecuta con un sintetizador simulado. + * Cubre los tres fallos intermitentes diagnosticados: + * 1. recolector de basura llevándose la locución a mitad de frase + * 2. motor atascado en pausa tras un cancel() (silencio total sin error) + * 3. voz que nunca arranca (bloqueo o voz rota) dejando el cartel pegado + */ +const fs = require('fs'); + +let fallos = 0; +function ok(desc, cond) { + console.log((cond ? ' ok ' : ' FALLA ') + desc); + if (!cond) fallos++; +} + +// ── Extraer el código real ────────────────────────────────────────────── +const src = fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8') + .replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"'); +const js = src.match(/]*>([\s\S]*?)<\/script>/)[1]; +const ini = js.indexOf('const VOCES_PREFERIDAS'); +const fin = js.indexOf('/* ── Fullscreen ── */'); +const cuerpo = 'let sonidoActivo=true,_vozES=null;function playBeep(){}' + + js.slice(ini, fin); + +ok('el código extraído contiene el anclaje anti-recolector', cuerpo.includes('_uttAncladas')); +ok('resume() se llama antes de cada speak()', + /resume\(\);\s*\n\s*window\.speechSynthesis\.speak\(utt\)/.test(cuerpo)); + +// ── Armazón de simulación ─────────────────────────────────────────────── +function escenario({ bloqueado = false, msArranque = 20 } = {}) { + let t = 0; const cola = []; + const st = (fn, ms) => cola.push({ t: t + ms, fn }); + class Utt { + constructor(x) { this.texto = x; this.l = {}; } + addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); } + emit(e, arg) { (this.l[e] || []).forEach(f => f(arg || {})); } + } + let resumeAntesDeSpeak = false, resumePendiente = false; + const synth = { + speaking: false, pending: false, + pause() {}, cancel() { synth.speaking = synth.pending = false; }, + resume() { resumePendiente = true; }, + getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }], + speak(u) { + resumeAntesDeSpeak = resumePendiente; resumePendiente = false; + if (bloqueado) return; + synth.pending = true; + st(() => { synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque); + st(() => { synth.speaking = false; u.emit('end'); }, msArranque + 4000); + }, + }; + const win = { speechSynthesis: synth }; + const beacons = []; + const sandbox = new Function( + 'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance', + 'localStorage', 'navigator', 'Blob', 'Date', + cuerpo + '; return { anunciarTurno, _uttAncladas };' + ); + const fakeDate = { now: () => t }; + fakeDate.prototype = Date.prototype; + const api = sandbox( + win, st, () => {}, Utt, + { getItem: () => '[]', setItem: () => {} }, + { sendBeacon: (u, b) => beacons.push(u) }, + function Blob() {}, + Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }) + ); + const correr = (hastaMs) => { + let n = 0; + while (cola.length && n++ < 500) { + cola.sort((a, b) => a.t - b.t); + const e = cola.shift(); + if (hastaMs !== undefined && e.t > hastaMs) { cola.unshift(e); break; } + t = e.t; e.fn(); + } + }; + return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t }; +} + +// ── 1. Anclaje: la locución queda referenciada mientras habla ─────────── +{ + const e = escenario(); + e.api.anunciarTurno('A01', 'Consultorio 2', null, () => {}); + e.correr(800); // ya se llamó a speak() + ok('durante el habla la locución está anclada (no la puede recoger el GC)', + e.api._uttAncladas.size === 1); + e.correr(); // hasta el final + ok('al terminar se suelta el anclaje (sin fuga de memoria)', + e.api._uttAncladas.size === 0); +} + +// ── 2. resume() antes de speak: motor atascado en pausa ───────────────── +{ + const e = escenario(); + e.api.anunciarTurno('A02', 'Consultorio 1', null, () => {}); + e.correr(); + ok('speak() siempre va precedido de resume() (destranca el motor pausado)', + e.resumeUsado()); +} + +// ── 3. Audio bloqueado: avisa para que el cartel no quede pegado ──────── +{ + const e = escenario({ bloqueado: true }); + let aviso = null; + e.api.anunciarTurno('A03', 'Consultorio 3', null, () => { aviso = e.t(); }); + e.correr(); + ok('con el audio bloqueado igual avisa y el cartel se puede cerrar', + aviso !== null && aviso < 10000); + ok('el anclaje también se suelta cuando no llegó a hablar', + e.api._uttAncladas.size === 0); +} + +// ── 4. Voz lenta (remota): no se corta ────────────────────────────────── +{ + const e = escenario({ msArranque: 2800 }); + let termino = false; + e.api.anunciarTurno('A04', 'Consultorio 4', 'maría gómez', () => { termino = true; }); + e.correr(); + ok('una voz que tarda 2,8 s en arrancar habla completa y termina', termino); +} + +// ── 5. La bitácora registra start y end ───────────────────────────────── +{ + const e = escenario(); + e.api.anunciarTurno('A05', 'Consultorio 5', null, () => {}); + e.correr(); + ok('la bitácora mandó al menos 2 eventos (start y end) al servidor', + e.beacons.length >= 2); +} + +console.log(); +console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.'); +process.exit(fallos === 0 ? 0 : 1);