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>
El vigía de congelamiento escucha visibilitychange, y el arnés no proveía
document: la suite entera se caía al cargar. Fue mi error de proceso además
del de código: empujé el commit anterior sin mirar el resultado de la suite,
que es exactamente lo que la suite existe para impedir.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Revisión de lo entregado ayer sin dar nada por hecho. Aparecieron dos
defectos reales y un vacío en las pruebas que los dejaba pasar.
1. DOBLE ANUNCIO (voz). En Chrome, cancel() dispara 'error' (interrupted)
sobre la locución vieja, y ese handler volvía a lanzar el reintento: dos
voces superpuestas diciendo lo mismo, intermitente — otra fuente del
"entrecortado". Cada locución toma ahora un token de generación; si al
dispararse un evento ya no es la vigente (la superó un reintento o el
anuncio siguiente), sus handlers solo sueltan el anclaje y callan. De paso
la bitácora deja de registrar 'end' falsos de locuciones canceladas.
2. FUGA ENTRE FICHAS (RIPS). La respuesta del sondeo puede llegar después de
que la recepcionista cambió de paciente: los exámenes de uno se cargaban
en la ficha del siguiente. Ahora se compara la cédula de la respuesta con
la de quien está en pantalla y, si no coincide, se descarta y se detiene.
3. EL SIMULADOR MENTÍA POR OMISIÓN. No imitaba que cancel() interrumpe con
'error', por eso el defecto 1 pasó las pruebas. Ahora sí lo hace, y además
modela los dos modos reales de fallo de Chrome: el speak descartado en
silencio (se reintenta) y la voz eternamente en pending (se tolera: es
indistinguible de una voz remota lenta, y cortarla fue el defecto del
entrecortado original).
Batería completa: 62 verificaciones en 4 suites, todas en verde.
test_tv_voz.js 16 incluye: exactamente 1 reintento, sin fantasmas
test_rips_sondeo.js 9 incluye: el guardián de ficha existe y corre antes
test_rips_ventana.php 6
test_bsuid.php 31
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
La suite probaba los fallos (recolector, motor pausado, bloqueo, voz lenta)
pero no que HABLE BIEN: qué texto dice exactamente, con qué voz, y cómo
pronuncia. Un anuncio que suena pero dice «MaríA GóMez» o elige la voz remota
teniendo la local también es un fallo, y no estaba vigilado.
Cuatro casos nuevos: el texto deletrea el código y cierra con el destino, las
tildes capitalizan bien, se prefiere la voz local sobre la remota es-CO, y sin
nombre de paciente igual habla. 13 verificaciones en total.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
── Voz que a veces no suena o se corta ──
Dos causas nuevas, ambas intermitentes por naturaleza:
1. Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
el recolector de basura puede llevársela A MITAD DE FRASE. El audio se corta
en seco y 'end' no llega. Depende de cuándo pase el recolector, así que
nunca se reproduce a voluntad. Cada locución queda anclada en un Set hasta
terminar (_uttAncladas).
2. Tras un cancel(), en Windows y ChromeOS el motor puede quedar en pausa:
speak() encola y jamás suena, sin error alguno. resume() antes de cada
speak() lo destranca, y sobre un motor sano no hace nada.
Y porque lo intermitente no se depura mirando la pantalla, queda una bitácora:
cada intento de hablar registra voz elegida, si es local, cuánto tardó en
arrancar y cómo terminó — en localStorage.tvVozLog (últimos 200) y en la tabla
turnero_tv_log vía sendBeacon (endpoint log_tv.php, insert-only, eventos de
lista cerrada). La próxima vez que reporten "ayer a las 10 no sonó", se
consulta la tabla y se ve qué pasó exactamente.
── Servicios de RIPS que "se demoran" ──
Medido en datos, no en hipótesis: la facturación en el sistema del laboratorio
ocurre MIENTRAS la recepcionista atiende. La consulta a RIPS se hacía UNA sola
vez, al vincular al paciente — y en 7 días, 547 de 549 registros llegaron
DESPUÉS de esa única consulta. Nadie volvía a preguntar: eso es lo que se
percibía como lentitud de RIPS. (El scheduler en sí tarda segundos; de paso:
el reloj del equipo legado está ~1 hora atrasado, visible en hora_recepcion.)
Ahora la ficha sondea cada 10 s hasta 10 minutos y se detiene al encontrar,
al limpiar la ficha o al agotar los intentos. Si la recepcionista ya marcó
exámenes a mano, no se le pisan: se le ofrece el banner en vez de autocargar.
── Pruebas (22, todas contra el código real extraído de las vistas) ──
scripts/test_tv_voz.js 9 anclaje GC, resume(), bloqueo, voz lenta, bitácora
scripts/test_rips_sondeo.js 7 reintentos, paradas, tope, sin duplicar
scripts/test_rips_ventana.php 6 ventana SQL en tabla TEMPORARY, sin tocar datos
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
57 números aparecen con dos identificadores (línea reciclada, o la persona
se registró de nuevo). El recorrido iba por BSUID, así que en esos casos los
dos escribían sobre el mismo usuario y el que quedaba dependía del orden:
cada ejecución dejaba un valor distinto y ninguna era más correcta. De ahí
los "110 reemplazos", que no eran 110 personas sino 57 escribiéndose dos
veces.
Ahora el empate se resuelve en memoria antes de tocar la base —gana el visto
más tarde, que es el vigente— y se escribe una sola vez por teléfono. La
corrida siguiente da 0 cambios y 0 conflictos, que es como debe comportarse
algo idempotente.
Estado final: 5.607 usuarios con su BSUID, sin duplicados, y ningún registro
con el identificador guardado en el campo de teléfono.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La ejecución real falló con "MySQL server has gone away" tras escribir 4.522
de 5.663: leer los 228.887 registros tarda más de media hora, y para cuando
empezaba a escribir el servidor ya había cerrado esa conexión. El singleton
de Database no sabe reconectar.
Ahora la lectura guarda el mapa en disco y termina; la segunda ejecución lo
toma de la caché en un segundo y escribe con la conexión sana. Reintentar
sale gratis, que era el otro problema: cada intento costaba media hora.
También informa cuántos teléfonos tienen más de un BSUID. La simulación
decía cero conflictos, pero no podía detectarlos: como no escribe, el bsuid
del usuario siempre estaba vacío y la comparación nunca daba positivo. Al
aplicar de verdad aparecieron. Ahora se cuentan al leer, sin depender de
lo ya escrito.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quedó fuera del commit anterior porque tests/ está en .gitignore, pese a
que el mensaje la mencionaba. Va en scripts/, que sí se versiona, junto al
backfill al que acompaña.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Meta no permite averiguar el teléfono a partir del BSUID: no hay endpoint de
consulta inversa, cada empresa debe llevar su propia equivalencia. Pero el
BSUID llega en TODOS los webhooks de mensaje, también en los que aún traen
teléfono, así que la equivalencia se puede ir guardando sola mientras la
persona todavía muestra su número.
- users.bsuid guarda esa equivalencia, y el webhook la anota en cada mensaje.
Cuando alguien oculte su número, se le seguirá reconociendo y respondiendo
a su teléfono de siempre.
- Va en columna aparte y no en phone_number porque el teléfono además cruza
con el paciente y el turnero; mezclarlos rompería esos cruces.
- Para quien nunca escribió mostrando el número queda pedirle el contacto:
pedirContacto() manda el botón request_contact_info, y el webhook atiende
el mensaje `contacts` que llega si acepta.
- Al vincular puede aparecer un segundo registro de la misma persona. No se
fusionan: una fusión mal hecha mezcla dos historias clínicas. Gana el que
tiene el teléfono, el otro queda anotado en el log para revisarlo a mano.
- scripts/backfill_bsuid.php carga las equivalencias del histórico (6.423),
con --simular para verlas sin escribir.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Nuevo módulo lab_examenes con lista paginada, buscador y filtro por categoría
- Vista de detalle/edición con campos completos de exam_tipos (CUPS, protocolo,
tipo muestra, nivel, abreviatura, seremite, ayuno, instrucciones)
- Sub-tabla inline de items de resultado (lab_items_resultado) con edición por fila
- Sub-tabla de precios por tarifa (lab_tarifas) con modal de edición
- APIs: list, get, save, save_item, save_tarifa, get_tarifas
- Script ETL Firebird→MySQL (scripts/etl_examenes.php) para la migración inicial
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>