Author SHA1 Message Date
lizandrogd aa0f4ac68a fix(docker): descargar redis PECL por HTTPS (el filtro de red devuelve 403 a los .tgz por HTTP) 2026-08-17 17:13:11 +00:00
Lizandro GuarnizoandClaude Opus 5 ccaf6f22cd Configuración: miniatura cuadrada en la lista de medios del televisor
La miniatura era de 90x60 con recorte, así que el material cuadrado se veía
achatado en Configuración y no coincidía con lo que después salía al aire.
Ahora es cuadrada, igual que la pantalla.

La carga en sí no necesitaba cambios: no valida proporciones ni dimensiones,
solo formato y tamaño, así que un video cuadrado siempre entró bien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:28:19 -05:00
Lizandro GuarnizoandClaude Opus 5 c7ff8e4ead Pantalla TV: el video pasa de formato reel a cuadrado de verdad
El cambio anterior hizo cuadrada la caja, pero la dejó dentro de la columna
fija del 29% —que es proporción de reel—, así que el cuadrado salía diminuto
y con medio espacio vertical vacío.

Ahora la columna se dimensiona sola (grid `auto`) y el cuadrado toma todo el
alto del cuerpo, deduciendo el ancho de ahí. Queda tan grande como quepa, con
tope de media pantalla para no ahogar la columna de llamados.

El aspect-ratio va en el elemento interno y no en el contenedor: con
box-sizing border-box, el padding asimétrico del contenedor deformaba la
proporción y el "cuadrado" no quedaba cuadrado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:22:10 -05:00
Lizandro GuarnizoandClaude Opus 5 645ed677ea Permitir abrir la prueba de voces en el televisor
La página sirve para averiguar qué voces tiene ESE equipo, así que hay que
abrirla en el televisor —donde no hay sesión iniciada, porque es un kiosco—.
Tal como quedó, redirigía al login y era inservible justo donde se necesita.

No expone nada: lista las voces que reporta el navegador y lee una frase de
ejemplo inventada. Ningún dato de paciente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:52:11 -05:00
Lizandro GuarnizoandClaude Opus 5 c525cc7261 Pantallas TV: caja cuadrada para el video, que es cuadrado
El material del reel es cuadrado, pero la caja ocupaba todo el alto de una
columna angosta. Con object-fit:cover eso significa recortar: del cuadrado
solo quedaba visible una franja vertical del centro, y se perdían los bordes
—que es justo donde suele ir el logo o el texto de una pieza publicitaria.

Ahora la caja es cuadrada (aspect-ratio 1/1) y se centra en su columna, así
que la pieza entra completa. cover se conserva: sobre una caja cuadrada con
material cuadrado no recorta nada, y si algún día suben algo de otra
proporción, lo encuadra en vez de deformarlo.

Mismo arreglo en display.php, que tiene su propio panel de video por ?video=
con el mismo defecto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:58 -05:00
Lizandro GuarnizoandClaude Opus 5 6589503c57 Tablet de firma para el paciente en Recepción 4
Hasta ahora, para que el paciente firmara el consentimiento de bienvenida
había que girarle el monitor a la recepcionista o pasarle el mouse: el botón
Firmar abre el formulario en la pantalla de ella.

Se agrega una pantalla aparte (/erp.php?m=turnero&v=firma) para poner frente
al paciente. Sola, sin que la recepcionista haga nada: cuando el turno entra
a ese puesto y le falta el F-LAB-01, aparece su nombre y un botón grande de
firmar; al terminar dice gracias y vuelve a reposo.

Reutiliza lo que ya existía: el formulario de ver_formulario_enviado.php, el
aviso 'turneroFirmado' que ya emite al firmar, y el sondeo de recepción, que
seguirá poniendo el renglón en verde sin cambios.

Sobre el acceso: va en PUBLIC_ROUTES porque nadie va a iniciar sesión cada
mañana en una tablet que manipula el público. No queda abierta: se identifica
por la cookie del dispositivo, y sin ella no muestra ningún dato. El endpoint
devuelve solo el turno que está en ese puesto en ese instante —no permite
buscar, ni ver otros, ni consultar historial— y descarta turnos de días
anteriores, igual que el televisor. La tablet debe quedar en modo kiosco.

Falta registrar el dispositivo de Recepción 4 desde Configuración; hoy no
tiene ninguno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:35:20 -05:00
Lizandro GuarnizoandClaude Opus 5 2a87f66bb7 Pantalla TV: dejar de llamar a pacientes que ya se fueron
Reportan que a veces llama a otro paciente. Sí ocurre, y hay 26 turnos sin
cerrar en estado "en servicio" desde el 28 de julio, varios de ellos dos y
tres en el mismo puesto a la vez.

La pantalla pregunta por cada puesto cuál turno está en servicio y se queda
con el más reciente. Mientras hay uno real todo va bien, pero al cerrarse
ese, el puesto queda con el viejo como único en servicio y sube al panel como
si lo estuvieran llamando. Peor: al cargar, la pantalla anunciaba POR VOZ el
turno que encontrara arriba, así que cada recarga —y se recarga sola— podía
gritar el nombre de alguien que se fue hace días.

- Al abrir ya no se anuncia nada: los turnos presentes se dan por vistos y
  solo se anuncia lo que pase de ahí en adelante. Mostrar el estado actual es
  correcto; declamarlo como si fuera un llamado nuevo, no.
- Un turno vale hasta la medianoche de su día, tanto en recepción como en los
  puestos. De los 26 atascados, 25 dejan de aparecer; queda el único de hoy.

El filtro es una red, no la solución: esos 26 turnos hay que cerrarlos, y
mientras existan siguen inflando el "en proceso" del dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:19:47 -05:00
Lizandro GuarnizoandClaude Opus 5 0ec0edf245 Toma de muestras: mostrar el F-LAB-08 en los turnos de solo entrega
Cuando recepción marca "solo entrega de muestras", el puesto de toma no
mostraba ningún formulario. No era que no se generara: el backend ya lo
creaba por la vía del lugar —los ocho puestos de toma, Pediatría y
Ginecología tienen vinculado el formulario 16— y era la interfaz la que lo
escondía. En 30 días: 59 turnos de solo entrega, 34 con el F-LAB-08 ya
creado, 0 firmados.

Eran tres bloqueos, no uno:
- la sección de formularios se ocultaba por solo_muestras, y como los puestos
  de toma van en modo embebido, esa sección es la única vía para abrirlo;
- no se cargaba el formulario embebido;
- ni se refrescaba la lista cada 5 s, así que al firmar no se habría visto
  el cambio.

Se conserva la exención del bloqueo para finalizar (línea 1685): el
formulario aparece y se puede llenar, pero un turno de solo entrega no queda
atrapado si no se firma. Cerrar la cola por esto sería peor que el problema
que resuelve.

Estos turnos no traen exámenes, así que la lista solo puede contener los
formularios del puesto: no hay riesgo de que aparezcan consentimientos de
exámenes que no aplican.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:20:47 -05:00
Lizandro GuarnizoandClaude Opus 5 b3a6be8f17 Pantalla TV: dejar de cortar la voz que arranca despacio
Dos defectos del mismo cambio de hoy, que explican lo que se oía.

La detección de "el navegador bloqueó el audio" daba 1,9 s de plazo y, si en
ese tiempo no había empezado a hablar, cancelaba y repetía con otra voz. Pero
una voz remota tarda en arrancar porque se baja de internet: el plazo la
declaraba muerta cuando iba a hablar, le cortaba la frase y la repetía con la
voz del navegador. De ahí que se oyera media frase y luego otra distinta, a
veces de hombre y a veces de mujer. Ahora se le pregunta al sintetizador si
está trabajando antes de darla por fallida, y el plazo sube a 3,2 s.

Y la voz se elegía al llegar el turno, no al hablar. La lista de voces la
carga el navegador de forma asíncrona, así que en los primeros llamados tras
abrir la página venía vacía y se hablaba con la voz por defecto. Ahora se
resuelve en el momento de hablar, cuando ya está cargada.

Comprobado: con voz instantánea, lenta (2 s) y muy lenta (4 s) ya no hay
corte y siempre habla Sabina; con el audio bloqueado se sigue avisando para
que el cartel no quede pegado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:08 -05:00
Lizandro GuarnizoandClaude Opus 5 8f94a8e012 Pantalla TV: el cartel se quedaba pegado cuando el navegador no dejaba hablar
Regresión del cambio anterior. Al arrancar con sonidoActivo=true,
anunciarTurno() devolvía el objeto de voz aunque el navegador estuviera
bloqueando el audio; el cartel esperaba el evento 'end', que en ese caso no
llega nunca, y quedaba fijo hasta la red de seguridad de 25 segundos. Antes
devolvía null y cerraba a los 5.

Ahora avisa por callback tanto al terminar de hablar como al quedar claro que
no va a hablar, detectado porque 'start' no ocurre. speak() no lanza nada ni
emite 'error' cuando lo bloquean: el silencio es la única señal.

Además, si la voz elegida no arranca —el equipo la lista pero no la puede
usar— se reintenta una vez con la del navegador en vez de quedarse mudo.
Comprobado con tres escenarios: equipo normal (cierra a 4,7 s), audio
bloqueado (3,1 s) y voz que no suena (5,9 s, hablando en el reintento).

Aparte: la expresión de mayúsculas usaba \w, que no cuenta letras acentuadas
y trataba la tilde como separador. "maría fernanda gómez" se pronunciaba
"MaríA Fernanda GóMez".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:12:53 -05:00
Lizandro GuarnizoandClaude Opus 5 7764f88df3 Pantalla TV: preferir Sabina y Paulina, que son locales
La voz es-CO de Chrome es de Google y se baja de internet en cada llamado:
con wifi flojo se corta. Una colombiana local no existe, así que se prefieren
las mexicanas del propio equipo —Sabina en Windows, Paulina en macOS—, que
para leer letras, números y un nombre propio se oyen naturales aquí.

El orden queda: las dos por nombre, luego cualquier local latinoamericana,
luego cualquier local aunque sea de España (peor acento, pero no se corta), y
la remota solo si el equipo no tiene ninguna instalada.

Comprobado contra cinco inventarios de voces: Windows con Sabina, macOS con
Paulina, solo España local, sin ninguna local, y sin voces en español.

voces.php replica el mismo orden para señalar cuál está sonando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:57:33 -05:00
Lizandro GuarnizoandClaude Opus 5 24c05a40c2 Pantalla TV: el sonido arranca encendido, sin esperar que alguien la toque
sonidoActivo empezaba en false y solo un clic lo encendía, así que cualquier
recarga —corte de red, reinicio, refresco— dejaba el televisor mudo hasta que
una persona fuera físicamente a tocarlo. Peor: con esa lógica ni siquiera
poniendo --autoplay-policy=no-user-gesture-required habría sonado, porque la
página se bloqueaba a sí misma antes de que el navegador opinara.

Ahora arranca encendido e intenta abrir el audio al cargar. Si el navegador
lo permite, habla sin que nadie intervenga. Si lo bloquea, se muestra el
letrero para que alguien toque una vez, que es el único camino que dejan las
políticas de reproducción automática.

El pito de confirmación queda solo para el clic humano: al arrancar sola, la
pantalla no tiene por qué pitar cada vez que se recarga. Y el letrero ya no
depende de localStorage sino de si el audio quedó realmente suspendido,
atado a onstatechange porque resume() es asíncrono y antes parpadeaba.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:45:52 -05:00
Lizandro GuarnizoandClaude Opus 5 db133d9b5d Pantalla TV: el pito tapaba la primera palabra, y página para elegir voz
El llamado se oía "entrecortado" por dos motivos distintos.

playBeep() y speak() salían en el mismo instante, y el pito dura 0,65 s con
volumen 0,35: "Turno A-01" se pronunciaba por debajo del tono y desde lejos
parecía que el anuncio empezaba ya empezado. Se vuelve a esperar los 700 ms
que el código tenía antes de que se quitaran.

El otro motivo es la voz. getVozES() pide es-CO, que en Chrome es la de
Google: remota. Cada llamado la baja de internet, así que con wifi flojo se
corta o no suena. Las voces disponibles las decide el televisor, no el ERP,
así que en vez de adivinar se agrega /erp.php?m=turnero&v=voces: lista las
voces del equipo, marca cuáles son locales y cuáles se bajan de internet,
señala la que está sonando hoy y deja oír cada una con el texto real de un
llamado. Hay que abrirla en el televisor.

Queda descartado el eco entre pantallas: hay un solo televisor con sonido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 06:38:06 -05:00
Lizandro GuarnizoandClaude Opus 5 cd087f65af Elegir un solo BSUID por teléfono antes de escribir
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>
2026-08-11 21:54:21 -05:00
Lizandro GuarnizoandClaude Opus 5 ffb8c3c934 Partir el backfill en dos fases para que no muera a mitad
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>
2026-08-11 21:08:14 -05:00
Lizandro GuarnizoandClaude Opus 5 6b74db3e4f Versionar la verificación del BSUID
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>
2026-08-11 20:26:54 -05:00
Lizandro GuarnizoandClaude Opus 5 2b1f08b03f Evitar teléfonos falsos en la historia clínica, y pedir el número a quien lo oculta
El arreglo anterior dejó un defecto: obtenerOCrearDesdeWhatsapp() copiaba
users.phone_number al teléfono de la ficha, y para quien oculta su número ahí
va el BSUID. normalizarTelefono() le quitaba el punto y las letras, así que
"CO.1761088155094242" quedaba guardado como "1761088155094242": un número de
16 dígitos, falso, con apariencia de real, dentro de una historia clínica y
sin que nadie lo notara. Ahora esa ficha se crea sin teléfono, que es la
verdad: no lo tenemos.

- esBsuid() queda definido una sola vez, en config.php, que es donde lo ven
  tanto el servicio de WhatsApp como las clases del laboratorio.
- La lista y el detalle de pacientes dicen "Solo por WhatsApp" en vez de
  mostrar el identificador crudo: recepción necesita entender por qué no
  puede llamar, no ver un código.
- Al crear la ficha desde una conversación se le pide el número con el botón
  de Meta, una sola vez (users.contacto_pedido_at). El texto es editable
  desde configuración. Solo se marca como pedido si Meta aceptó el envío,
  para poder reintentar si falló.
- tests/test_bsuid.php: 30 verificaciones sobre identidad, envío, vinculación
  y creación de fichas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:26:31 -05:00
18 changed files with 1110 additions and 96 deletions
+6 -1
View File
@@ -36,7 +36,12 @@ RUN apk add --no-cache curl-dev \
opcache
# Instalar Redis extension (versión fija para cache reproducible)
RUN pecl install redis-6.0.2 && docker-php-ext-enable redis
# Se descarga por HTTPS de forma explícita: el filtro de red perimetral
# responde 403 a los .tgz servidos por HTTP, lo que rompía "pecl install".
RUN apk add --no-cache curl \
&& curl -fsSL https://pecl.php.net/get/redis-6.0.2.tgz -o /tmp/redis-6.0.2.tgz \
&& pecl install /tmp/redis-6.0.2.tgz \
&& docker-php-ext-enable redis
# Instalar Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+57 -1
View File
@@ -21,6 +21,53 @@ requireAuthentication();
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
$db = Database::getInstance();
/**
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
*
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
* recibe el teléfono y lo vincula solo.
*
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
* falla, porque la ficha es lo importante.
*
* @return string qué pasó, para que la interfaz lo pueda mostrar
*/
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
$u = $db->fetch(
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
[$userId]
);
if (!$u) return 'usuario_no_encontrado';
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
try {
require_once __DIR__ . '/../../services/WhatsAppService.php';
$wa = new WhatsAppService();
$texto = getConfigFromDB(
'whatsapp_texto_pedir_contacto',
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
);
$r = $wa->pedirContacto($u['phone_number'], $texto);
if (!$r) {
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
return 'fallo_envio';
}
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
return 'pedido';
} catch (Exception $e) {
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
return 'fallo_envio';
}
}
// ── GET: solo_paciente ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$convId = (int)($_GET['conversation_id'] ?? 0);
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$pacienteRepo = new Paciente();
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
$paciente = $pacienteRepo->obtener($pacienteId);
echo json_encode(['success' => true, 'paciente' => $paciente]);
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
echo json_encode([
'success' => true,
'paciente' => $paciente,
'contacto_pedido' => $contactoPedido,
]);
exit;
}
+20 -2
View File
@@ -7,6 +7,15 @@
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
// directamente, sin pasar por el arranque del ERP.
if (!function_exists('esBsuid')) {
$configPaciente = __DIR__ . '/../../config/config.php';
if (file_exists($configPaciente)) {
require_once $configPaciente;
}
}
class Paciente {
private Database $db;
@@ -258,10 +267,19 @@ class Paciente {
[$userId]
);
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
// en la historia clínica un dato falso con apariencia de número real, porque
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
$identificador = $user['phone_number'] ?? null;
$esIdentificadorSinTelefono = esBsuid($identificador);
return $this->crear([
'user_id' => $userId,
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
'telefono' => $user['phone_number'] ?? null,
'nombre_completo'=> $user['name']
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
]);
}
+18
View File
@@ -339,6 +339,24 @@ function deleteConfigFromDB($key) {
}
}
if (!function_exists('esBsuid')) {
/**
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
*
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
*
* @param mixed $valor
* @return bool
*/
function esBsuid($valor) {
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
}
}
/**
* Limpia el cache estático de configuraciones
*/
+8
View File
@@ -23,6 +23,14 @@ class Router
private const PUBLIC_ROUTES = [
'turnero/display',
'turnero/kiosko',
// Tablet de firma del paciente: la manipula el público y nadie va a
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
// por la cookie del dispositivo y sin ella no muestra dato alguno.
'turnero/firma',
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
// lista las voces del navegador y lee una frase de ejemplo inventada.
'turnero/voces',
];
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
+35 -3
View File
@@ -472,6 +472,28 @@ function fmtFecha(str) {
return str.slice(0, 10).split('-').reverse().join('/');
}
// Quien oculta su número en WhatsApp llega identificado con un BSUID
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
/**
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
* necesita entender por qué no puede llamarla, no ver un código.
*/
function celdaTelefono(p) {
if (esBsuid(p.phone_number)) {
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
+ '<br><i class="fab fa-whatsapp text-success"></i> '
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
}
return esc(p.telefono || '—')
+ (p.phone_number
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
: '');
}
async function cargarLista(pag = 1) {
paginaActual = pag;
const busq = document.getElementById('buscador').value.trim();
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
</td>
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
<td class="small">${celdaTelefono(p)}</td>
<td class="small text-muted">${esc(p.email||'—')}</td>
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
<td class="small text-muted">${esc(p.eps||'—')}</td>
@@ -546,8 +568,18 @@ async function verDetalle(id) {
document.getElementById('detail-body').innerHTML = `
<dl class="row small mb-3">
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
p.telefono ? esc(p.telefono)
: (esBsuid(p.phone_number)
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
: '—')
}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
!p.phone_number ? '—'
: (esBsuid(p.phone_number)
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
}</dd>
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
@@ -0,0 +1,24 @@
-- 20260811_bsuid_pedir_contacto.sql
--
-- Tercera parte del cambio de identidad de WhatsApp.
--
-- A quien oculta su teléfono se le puede pedir con el botón request_contact_info.
-- Se registra cuándo se le pidió para no volver a insistirle: pedirle los datos
-- una vez es razonable, repetírselo en cada trámite es acoso.
--
-- Queda NULL para todo el mundo; solo se llena cuando efectivamente se pide.
ALTER TABLE users
ADD COLUMN contacto_pedido_at DATETIME NULL DEFAULT NULL
COMMENT 'Cuándo se le pidió el teléfono por el botón de WhatsApp. NULL = nunca.'
AFTER bsuid;
-- Texto editable desde configuración, para que el laboratorio ajuste el mensaje
-- sin tocar código. Si la fila ya existe, se respeta lo que haya.
INSERT INTO system_config (config_key, config_value, description)
VALUES (
'whatsapp_texto_pedir_contacto',
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?',
'Mensaje del botón que pide el teléfono a quien lo tiene oculto en WhatsApp'
)
ON DUPLICATE KEY UPDATE config_key = config_key;
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado = 'en_recepcion'
-- Misma regla que en los puestos: el turno vale hasta medianoche
AND DATE(t.llamado_recepcion_at) = CURDATE()
ORDER BY t.llamado_recepcion_at DESC"
);
$stmtRec->execute([$sesionId]);
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
WHERE t.sesion_id = ?
AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ?
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
-- a un paciente de días atrás en cuanto se cerraba el turno real.
AND DATE(t.llamado_lugar_at) = CURDATE()
ORDER BY t.llamado_lugar_at DESC
LIMIT 1"
);
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* GET /modules/turnero/api/get_firma_pendiente.php
*
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
*
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
*
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
* Sin dispositivo reconocido no responde nada.
*/
require_once __DIR__ . '/../../../config/config.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
/** El único formulario que se firma en esta tablet: el de bienvenida. */
const FORMULARIO_BIENVENIDA = 17;
function responder(array $datos): void {
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = Database::getInstance()->getConnection();
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
$token = trim($_COOKIE['turnero_token'] ?? '');
if ($token === '') {
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
}
$stmt = $pdo->prepare(
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, tl.tipo
FROM turnero_dispositivos td
JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.token = ? AND td.activo = 1
LIMIT 1"
);
$stmt->execute([$token]);
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$disp || $disp['tipo'] !== 'recepcion') {
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
}
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
// medianoche de su día, para no mostrar a un paciente que ya se fue.
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id
WHERE t.estado = 'en_recepcion'
AND t.recepcion_desk_id = ?
AND DATE(t.llamado_recepcion_at) = CURDATE()
AND s.fin_at IS NULL
ORDER BY t.llamado_recepcion_at DESC
LIMIT 1"
);
$stmt->execute([(int)$disp['lugar_id']]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
}
// Sin paciente vinculado no hay a quién atribuirle la firma
if (empty($turno['paciente_id'])) {
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
}
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
$stmt = $pdo->prepare(
"SELECT token, estado FROM turnero_consentimientos
WHERE turno_id = ? AND formulario_id = ?
LIMIT 1"
);
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
responder([
'ok' => true,
'estado' => 'firmado',
'turno_id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
]);
}
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
if (!$consent) {
$tokenFirma = 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)
);
$ins = $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos
(turno_id, formulario_id, token, estado, creado_por)
VALUES (?, ?, ?, 'pendiente', NULL)"
);
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
// se relee para quedarse con el token que realmente quedó guardado.
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$consent) {
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
}
}
responder([
'ok' => true,
'estado' => 'por_firmar',
'turno_id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
'paciente' => $turno['paciente_nombre'],
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
]);
+5 -2
View File
@@ -1160,10 +1160,13 @@ $tab = $_GET['tab'] ?? 'lugares';
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
style="border:1px solid #e2e8f0;border-radius:10px">
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
<?php /* Miniatura cuadrada, igual que la pantalla del televisor: con la
antigua de 90x60 el material cuadrado se veía recortado aquí y
no coincidía con lo que después salía al aire. */ ?>
<?php if ($m['tipo'] === 'video'): ?>
<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>"></video>
<video muted style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>"></video>
<?php else: ?>
<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>" alt="">
<img style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>" alt="">
<?php endif; ?>
<div class="flex-grow-1">
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
+7 -3
View File
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
</div>
<!-- Panel publicitario (se activa con ?video=URL) -->
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative">
<!-- El material es cuadrado: la caja también, y centrada. Estirada a
toda la altura de una columna angosta, cover recortaba los bordes
del video y solo se veía la franja central. -->
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative;
align-items:center;justify-content:center">
<video id="video-pub" autoplay muted loop playsinline
style="width:100%;height:100%;object-fit:cover">
style="width:100%;height:auto;aspect-ratio:1/1;max-height:100%;object-fit:cover">
<source id="video-src" src="">
</video>
</div>
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
const body = document.getElementById('display-body');
src.src = videoUrl;
vid.load();
panel.style.display = 'block';
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
// 3 columnas: turno | video | cola
body.style.gridTemplateColumns = '1fr 320px 280px';
})();
+189 -60
View File
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
overflow: hidden;
position: relative;
}
/* La columna del video se dimensiona sola: `auto` la deja del ancho que
pida su contenido, y el contenido es un cuadrado tan alto como el
cuerpo. Antes era una franja fija del 29% —proporción de reel—, así
que un video cuadrado quedaba diminuto y con medio espacio vacío. */
.pg-body.has-video {
grid-template-columns: 1fr 29%;
grid-template-columns: 1fr auto;
}
.pg-body.has-video .turno-activo { display: none; }
@@ -240,16 +244,29 @@ $_hasVideo = (bool)$_tvPlaylist;
}
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
/* ── Video reel ── */
/* ── Video cuadrado ──
El material ya no es de proporción reel sino cuadrado. La caja toma
todo el alto disponible y el ancho lo deduce de ahí (aspect-ratio),
así que el cuadrado sale tan grande como quepa. Se limita a la mitad
de la pantalla para no ahogar la columna de los llamados. */
.reel-wrap {
display: none;
height: 100%;
max-width: 50vw;
overflow: hidden;
padding: 8px 8px 8px 0;
align-items: center;
justify-content: center;
}
.reel-wrap.has-video { display: block; }
.reel-wrap.has-video { display: flex; }
/* El cuadrado se define aquí y no en el contenedor: con box-sizing
border-box, el padding asimétrico del contenedor deformaría la
proporción. La columna se ajusta sola al ancho que resulte. */
.reel-wrap .reel-inner {
width: 100%; height: 100%;
height: 100%;
width: auto;
aspect-ratio: 1 / 1;
max-width: 100%;
border-radius: 16px;
overflow: hidden;
box-shadow: -8px 0 30px rgba(0,0,0,.08);
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
<div class="dot"></div>
</div>
<div id="sonido-banner" onclick="activarSonido()" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
<div id="sonido-banner" onclick="activarSonido(true)" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
</div>
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
<div class="hd-right">
<div class="live-dot"></div>
<div class="reloj" id="reloj">--:--:--</div>
<button class="btn-fs" id="btn-sonido" onclick="activarSonido()" title="Activar sonido">
<button class="btn-fs" id="btn-sonido" onclick="activarSonido(true)" title="Activar sonido">
<i class="fas fa-volume-mute"></i>
</button>
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
@@ -499,14 +516,22 @@ function tick() {
tick(); setInterval(tick, 1000);
/* ── Audio ── */
let audioCtx = null, sonidoActivo = false;
// Arranca encendido. Esta pantalla vive en un televisor que nadie toca: si el
// sonido esperara un clic, cualquier recarga —corte de red, reinicio, refresco—
// la dejaba muda hasta que alguien fuera físicamente a tocarla, que es lo que
// venía pasando. Si el navegador termina bloqueándolo, se avisa con el letrero.
let audioCtx = null, sonidoActivo = true;
function activarSonido() {
function activarSonido(porGesto) {
try {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} else if (audioCtx.state === 'suspended') {
audioCtx.resume();
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
// porque el estado todavía decía "suspended" cuando ya iba a sonar.
audioCtx.onstatechange = revisarBloqueo;
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
@@ -516,13 +541,39 @@ function activarSonido() {
localStorage.setItem('turneroSonido', '1');
const btn = document.getElementById('btn-sonido');
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
const banner = document.getElementById('sonido-banner');
if (banner) banner.style.display = 'none';
setTimeout(playBeep, 100);
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
// pantalla no tiene por qué pitar cada vez que se recarga.
if (porGesto) setTimeout(playBeep, 100);
revisarBloqueo();
}
// Sin once:true para que cualquier clic reactive si la página recargó
document.addEventListener('click', () => { if (!sonidoActivo) activarSonido(); });
document.addEventListener('touchstart', () => { if (!sonidoActivo) activarSonido(); });
/**
* El navegador puede negarse a sonar sin un gesto humano. No hay forma de
* saberlo preguntando, solo mirando si el contexto quedó suspendido: si es así
* se muestra el letrero para que alguien toque la pantalla una vez.
*
* Se evita del todo abriendo Chrome en el televisor con
* --autoplay-policy=no-user-gesture-required
*/
function revisarBloqueo() {
const banner = document.getElementById('sonido-banner');
if (!banner) return;
const bloqueado = !audioCtx || audioCtx.state === 'suspended';
banner.style.display = bloqueado ? 'flex' : 'none';
}
// Intento de arranque automático, en cuanto la página está lista
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => activarSonido(false));
} else {
activarSonido(false);
}
// Reintento: Chrome a veces deja el contexto suspendido un instante al abrir
setTimeout(() => { if (audioCtx && audioCtx.state === 'suspended') activarSonido(false); }, 2000);
// Cualquier gesto sirve de respaldo si el navegador bloqueó el arranque
document.addEventListener('click', () => activarSonido(true));
document.addEventListener('touchstart', () => activarSonido(true));
function playBeep() {
if (!sonidoActivo || !audioCtx) return;
@@ -544,17 +595,41 @@ function playBeep() {
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
let _vozES = null;
// Voces preferidas, por nombre. Sabina (Windows) y Paulina (macOS) son las
// mexicanas locales: salen del propio equipo, así que nunca se cortan. Una
// colombiana local no existe —la es-CO de Chrome es de Google y se baja de
// internet en cada llamado—, y para leer letras, números y un nombre propio
// el acento mexicano se oye natural aquí; el de España no.
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
function getVozES() {
if (_vozES) return _vozES;
const voices = window.speechSynthesis.getVoices();
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voices.find(v => v.lang === lang);
if (v) { _vozES = v; return v; }
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
if (!voces.length) return null;
const locales = voces.filter(v => v.localService);
const elegir = v => { _vozES = v; return v; };
// 1. Las pedidas por nombre, si están instaladas
for (const nombre of VOCES_PREFERIDAS) {
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
if (v) return elegir(v);
}
const v = voices.find(v => v.lang.startsWith('es'));
if (v) { _vozES = v; }
return _vozES;
// 2. Cualquier otra local latinoamericana
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
const v = locales.find(v => v.lang === lang);
if (v) return elegir(v);
}
// 3. Cualquier local, aunque sea de España: peor acento, pero no se corta
if (locales.length) return elegir(locales[0]);
// 4. Sin ninguna local, queda la remota. Suena mejor, pero depende del wifi:
// es la que venía entrecortándose.
for (const lang of ['es-CO', 'es-419', 'es-MX', 'es-US', 'es-ES']) {
const v = voces.find(v => v.lang === lang);
if (v) return elegir(v);
}
return elegir(voces[0]);
}
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
@@ -568,36 +643,90 @@ if ('speechSynthesis' in window) {
window.speechSynthesis.resume();
}, 10000);
}
// Auto-restore sonido si estaba activo antes de un reload
if (localStorage.getItem('turneroSonido') === '1') {
const banner = document.getElementById('sonido-banner');
if (banner) banner.style.display = 'flex';
}
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
function anunciarTurno(codigo, destino, paciente) {
// Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba
// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado.
// A nivel de archivo porque showAnnouncement() también lo necesita.
const BEEP_MS = 700;
/**
* Dice el llamado en voz alta.
*
* Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a
* hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa
* señal, y si nunca llegaba se quedaba pegado en pantalla.
*
* Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede
* usar— se reintenta una vez con la del navegador, en vez de quedarse mudo.
*
* @return bool si se intentó hablar. Falso significa pantalla sin sonido.
*/
function anunciarTurno(codigo, destino, paciente, onFin) {
playBeep();
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
const letras = codigo.split('').join(' ');
const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null;
// \w no cuenta las letras acentuadas, así que trataba la tilde como
// separador: "maría" salía "MaríA" y la voz lo pronunciaba raro.
const nomVoz = paciente
? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase())
: null;
let texto = `Turno ${letras}`;
if (nomVoz) texto += `, ${nomVoz}`;
texto += `, pase a ${destino}`;
let avisado = false;
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
const decir = (esReintento) => {
const utt = new SpeechSynthesisUtterance(texto);
utt.lang = 'es-CO';
const voz = getVozES();
if (voz) utt.voice = voz;
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
// Con el sintetizador libre se habla de inmediato. Solo cuando hay algo en
// curso hace falta cancelar y esperar un instante: Chrome ignora un speak()
// encadenado a un cancel() en el mismo ciclo.
let arranco = false;
utt.addEventListener('start', () => { arranco = true; });
utt.addEventListener('end', avisar);
utt.addEventListener('error', () => 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
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
setTimeout(() => {
if (arranco) return;
// Antes de darla por fallida hay que preguntarle al sintetizador:
// una voz remota tarda en arrancar porque se baja de internet, y
// 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; }
window.speechSynthesis.cancel();
decir(true); // segunda oportunidad, con la voz del navegador
}, margen);
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
// speak() encadenado a un cancel() en el mismo ciclo.
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
window.speechSynthesis.cancel();
setTimeout(() => window.speechSynthesis.speak(utt), 80);
} else {
window.speechSynthesis.speak(utt);
}
return utt;
setTimeout(() => {
// La voz se elige aquí y no al crear el anuncio: el navegador carga
// la lista de forma asíncrona, y en los primeros llamados tras abrir
// la página todavía venía vacía. Entonces no se asignaba voz y
// hablaba la del navegador —de ahí que a veces sonara un hombre y a
// veces una mujer—. A esta altura ya está cargada.
if (!esReintento) {
const voz = getVozES();
if (voz) utt.voice = voz;
}
window.speechSynthesis.speak(utt);
}, esReintento ? 0 : BEEP_MS);
};
decir(false);
return true;
}
/* ── Fullscreen ── */
@@ -642,8 +771,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
ov.classList.add('visible');
const utt = anunciarTurno(codigo, destino, paciente);
bar.style.transition = 'none';
bar.style.width = '100%';
requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -668,11 +795,15 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
};
if (utt) {
utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400));
utt.addEventListener('error', cerrarCuandoToque);
// Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo
setTimeout(dismiss, 25000);
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
// pantallas donde el navegador bloquea el audio.
const intentoHablar = anunciarTurno(codigo, destino, paciente,
() => setTimeout(cerrarCuandoToque, 400));
if (intentoHablar) {
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
setTimeout(dismiss, 20000);
} else {
setTimeout(dismiss, MIN_VISIBLE);
}
@@ -727,18 +858,16 @@ function renderSnapshot(snap) {
});
lastSeenKeys = currentKeys;
if (_firstLoad) {
_firstLoad = false;
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
if (sorted.length > 0) {
const t = sorted[0];
const c = t.prioridad_color || '<?= $_labColor ?>';
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
announcedCalls.add(key);
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
}
}
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
//
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
if (_firstLoad) _firstLoad = false;
if (anuncioNuevo) renderLlamados();
+200
View File
@@ -0,0 +1,200 @@
<?php
/**
* modules/turnero/views/firma.php
* Tablet de firma del paciente — /erp.php?m=turnero&v=firma
*
* Pantalla de cara al público: se pone frente al paciente en el mostrador y
* muestra, sola, el consentimiento de bienvenida cuando le toca firmarlo.
* Antes había que girarle el monitor a la recepcionista o pasarle el mouse.
*
* La tablet se identifica por la cookie del dispositivo, registrada desde
* Configuración. Sin ella no muestra nada: es lo que impide que cualquiera
* abra esta dirección y vea el nombre del paciente de turno.
*
* Conviene dejarla en modo kiosco, sin barra de direcciones, para que desde
* ella no se pueda navegar al resto del ERP.
*/
$_fCfg = [];
try {
$__pdo = Database::getInstance()->getConnection();
$_fCfg = $__pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
} catch (\Throwable $_) {}
$_fNombre = htmlspecialchars($_fCfg['empresa_nombre'] ?? 'Laboratorio');
$_fLogo = $_fCfg['doc_logo_base64'] ?? '';
$_fColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_fCfg['doc_color'] ?? '') ? $_fCfg['doc_color'] : '#1565c0';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Firma · <?= $_fNombre ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --brand: <?= $_fColor ?>; }
html, body {
height: 100%; width: 100%; overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
background: #f8fafc; color: #1e293b;
-webkit-user-select: none; user-select: none;
}
.pantalla {
height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 4vh 5vw; gap: 2.5vh;
}
.pantalla.oculta { display: none; }
.logo { max-height: 14vh; max-width: 50vw; object-fit: contain; }
.marca { font-size: clamp(1.2rem, 3vw, 2rem); font-weight: 700; color: #64748b; }
.saludo { font-size: clamp(1.4rem, 4vw, 2.6rem); font-weight: 600; color: #94a3b8; }
.etiqueta { font-size: clamp(.9rem, 2vw, 1.2rem); font-weight: 700;
letter-spacing: 3px; text-transform: uppercase; color: #94a3b8; }
.paciente { font-size: clamp(1.8rem, 5.5vw, 3.4rem); font-weight: 800; line-height: 1.15; }
.codigo { font-size: clamp(1.1rem, 2.6vw, 1.6rem); font-weight: 700; color: var(--brand); }
/* Botón deliberadamente enorme: lo va a tocar gente mayor, de pie y de afán */
.btn-firmar {
margin-top: 2vh;
background: var(--brand); color: #fff; border: 0;
border-radius: 18px; cursor: pointer;
font-family: inherit; font-weight: 800;
font-size: clamp(1.5rem, 4.5vw, 2.6rem);
padding: clamp(1rem, 3.5vh, 2.2rem) clamp(2.5rem, 12vw, 6rem);
box-shadow: 0 10px 30px rgba(0,0,0,.18);
transition: transform .12s ease, filter .12s ease;
}
.btn-firmar:active { transform: scale(.96); filter: brightness(.92); }
.ok-icono { font-size: clamp(3.5rem, 12vw, 7rem); color: #16a34a; line-height: 1; }
.ok-txt { font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 700; color: #166534; }
.aviso { font-size: clamp(1rem, 2.4vw, 1.4rem); color: #94a3b8; max-width: 34ch; line-height: 1.5; }
/* El formulario ocupa toda la pantalla al abrirse */
#marco { position: fixed; inset: 0; z-index: 50; background: #fff; display: none; }
#marco.abierto { display: block; }
#marco iframe { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<!-- En reposo: nadie a quien pedirle firma -->
<div class="pantalla" id="p-reposo">
<?php if ($_fLogo): ?><img class="logo" src="<?= htmlspecialchars($_fLogo) ?>" alt=""><?php endif; ?>
<div class="marca"><?= $_fNombre ?></div>
<div class="saludo">Bienvenido</div>
</div>
<!-- Hay alguien y le falta firmar -->
<div class="pantalla oculta" id="p-firmar">
<div class="etiqueta">Turno <span id="f-codigo"></span></div>
<div class="paciente" id="f-paciente"></div>
<div class="aviso">Por favor lea y firme el consentimiento para continuar con su atención.</div>
<button class="btn-firmar" id="btn-firmar">Firmar</button>
</div>
<!-- Ya firmó -->
<div class="pantalla oculta" id="p-gracias">
<div class="ok-icono">✓</div>
<div class="ok-txt">¡Gracias!</div>
<div class="aviso">Su consentimiento quedó registrado. Puede continuar en el mostrador.</div>
</div>
<!-- La tablet no está registrada -->
<div class="pantalla oculta" id="p-sin-registro">
<div class="marca"><?= $_fNombre ?></div>
<div class="aviso">Esta tablet todavía no está asignada a un puesto.<br>
Regístrela desde Configuración del turnero.</div>
</div>
<div id="marco"><iframe id="marco-iframe" src="about:blank"></iframe></div>
<script>
const API = '<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/';
let turnoEnPantalla = null; // turno que se está mostrando
let firmando = false; // con el formulario abierto no se cambia de pantalla
function mostrar(id) {
['p-reposo','p-firmar','p-gracias','p-sin-registro']
.forEach(p => document.getElementById(p).classList.toggle('oculta', p !== id));
}
function abrirFormulario(url) {
firmando = true;
document.getElementById('marco-iframe').src = url;
document.getElementById('marco').classList.add('abierto');
}
function cerrarFormulario() {
firmando = false;
document.getElementById('marco').classList.remove('abierto');
document.getElementById('marco-iframe').src = 'about:blank';
}
document.getElementById('btn-firmar').addEventListener('click', () => {
const url = document.getElementById('btn-firmar').dataset.url;
if (url) abrirFormulario(url);
});
async function revisar() {
// Con el formulario abierto no se toca la pantalla: el paciente está firmando
if (firmando) return;
let d;
try {
const r = await fetch(API + 'get_firma_pendiente.php', { cache: 'no-store' });
d = await r.json();
} catch (_) {
return; // Sin red se deja lo que haya puesto; ya volverá
}
if (!d.ok) {
if (d.motivo === 'sin_dispositivo') mostrar('p-sin-registro');
return;
}
if (d.estado === 'por_firmar') {
document.getElementById('f-codigo').textContent = d.codigo || '';
document.getElementById('f-paciente').textContent = d.paciente || '';
document.getElementById('btn-firmar').dataset.url = d.url || '';
turnoEnPantalla = d.turno_id;
mostrar('p-firmar');
return;
}
if (d.estado === 'firmado') {
// El "gracias" solo tiene sentido para quien acaba de firmar aquí.
// Si la tablet se abre con un turno ya firmado de antes, va a reposo.
mostrar(turnoEnPantalla === d.turno_id ? 'p-gracias' : 'p-reposo');
return;
}
// reposo: se olvida el turno anterior para no dejar datos de un paciente
// en pantalla mientras llega el siguiente
turnoEnPantalla = null;
mostrar('p-reposo');
}
// ver_formulario_enviado.php avisa al terminar; si no llega el aviso, el sondeo
// se encarga igual cuando el consentimiento aparezca como firmado.
window.addEventListener('message', (e) => {
const t = e.data && e.data.type;
if (t === 'turneroFirmado') {
cerrarFormulario();
mostrar('p-gracias');
setTimeout(revisar, 2500);
}
});
revisar();
setInterval(revisar, 2000);
</script>
</body>
</html>
+13 -7
View File
@@ -1355,9 +1355,11 @@ async function seleccionarSinLlamar(turnoId) {
mostrarCabeceraTurno(t);
await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId);
if (!_esSoloEntrega(t))
// También para solo entrega de muestras: a esos turnos les corresponde el
// formulario del puesto, así que la lista tiene que refrescarse igual o al
// firmar no se vería el cambio.
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(t.id);
mostrarFichaMobile();
}
@@ -1400,9 +1402,8 @@ async function abrirFicha(turno) {
mostrarCabeceraTurno(turno);
await cargarFichaSolicitud(turno.id);
clearInterval(pollingConsentId);
if (!_esSoloEntrega(turno))
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(turno.id);
mostrarFichaMobile();
}
@@ -1468,10 +1469,15 @@ async function cargarFichaSolicitud(turnoId) {
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
// Ocultar sección consentimientos y formulario embebido cuando es solo entrega
// La sección de formularios ya NO se esconde por ser solo entrega de
// muestras. Esos turnos no traen exámenes, pero sí les corresponde el
// formulario del puesto (F-LAB-08, Datos Toma de Muestras): el backend
// se lo venía creando y la interfaz lo ocultaba, así que se acumulaban
// creados y sin firmar. Quién se muestra lo decide renderConsentimientos
// según lo que realmente haya que llenar.
const secConsent = document.getElementById('sec-consent');
const secForm = document.getElementById('sec-form-embebido');
if (secConsent) secConsent.classList.toggle('d-none', esSoloMuestras);
if (secConsent) secConsent.classList.remove('d-none');
if (secForm) secForm.classList.add('d-none');
// Embarazada
@@ -2230,7 +2236,7 @@ function resetFicha() {
_muestrasActivas = [];
const secMuestras = document.getElementById('sec-muestras');
if (secMuestras) secMuestras.classList.add('d-none');
// Restaurar sección consent (puede haber sido ocultada por turno solo-muestras)
// Dejar la sección visible para la siguiente ficha
document.getElementById('sec-consent')?.classList.remove('d-none');
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
document.getElementById('ficha-orden').classList.add('d-none');
+154
View File
@@ -0,0 +1,154 @@
<?php
/**
* modules/turnero/views/voces.php
* Prueba de voces del televisor — /erp.php?m=turnero&v=voces
*
* Las voces disponibles no las decide el ERP sino el equipo: dependen del
* sistema operativo y del navegador del televisor. Por eso esta página hay que
* abrirla EN EL TELEVISOR, no en el computador de la oficina: cada equipo
* ofrece una lista distinta.
*
* Sirve para oír cada voz con el texto real de un llamado y elegir la que
* quede. Distingue las locales de las que se bajan de internet, que son las
* que se entrecortan cuando el wifi flaquea.
*/
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de voces · Turnero</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0f172a; color: #e2e8f0;
}
h1 { font-size: 1.5rem; margin: 0 0 4px; }
.sub { color: #94a3b8; font-size: .95rem; margin-bottom: 20px; }
.aviso {
background: #1e293b; border-left: 4px solid #38bdf8;
padding: 12px 16px; border-radius: 6px; margin-bottom: 22px;
font-size: .92rem; line-height: 1.5;
}
.fila {
display: flex; align-items: center; gap: 14px;
background: #1e293b; border-radius: 8px;
padding: 14px 16px; margin-bottom: 10px;
border: 1px solid #334155;
}
.fila.elegida { border-color: #38bdf8; background: #1e3a52; }
.nom { font-weight: 600; flex: 1; min-width: 0; }
.nom small { display: block; font-weight: 400; color: #94a3b8; font-size: .8rem; }
.tag {
font-size: .72rem; padding: 3px 9px; border-radius: 99px;
white-space: nowrap; font-weight: 600;
}
.local { background: #14532d; color: #86efac; }
.remota { background: #7c2d12; color: #fdba74; }
.actual { background: #0c4a6e; color: #7dd3fc; }
button {
background: #0284c7; color: #fff; border: 0;
padding: 10px 20px; border-radius: 6px; cursor: pointer;
font-size: .95rem; font-weight: 600; white-space: nowrap;
}
button:hover { background: #0369a1; }
#vacio { color: #94a3b8; padding: 20px 0; }
</style>
</head>
<body>
<h1>Prueba de voces</h1>
<div class="sub">Ábrala <strong>en el televisor</strong>: cada equipo ofrece voces distintas.</div>
<div class="aviso">
Las <span class="tag local">local</span> salen del propio equipo: nunca se cortan, aunque suenen
algo más robóticas.<br>
Las <span class="tag remota">de internet</span> suenan más naturales, pero se bajan de los
servidores de Google en cada llamado. Con wifi débil se entrecortan o no suenan.
<br><br>
Oiga las que aparezcan y dígame cuál prefiere.
</div>
<div id="lista"><div id="vacio">Cargando voces…</div></div>
<script>
// El mismo texto que dice la pantalla de turnos, para juzgarlas en condiciones
const TEXTO = 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2';
// Réplica de la preferencia de display_global.php, para señalar cuál está
// sonando. Si allá se cambia el orden, hay que cambiarlo aquí también.
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
function vozActual(voces) {
if (!voces.length) return null;
const locales = voces.filter(v => v.localService);
for (const nombre of VOCES_PREFERIDAS) {
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
if (v) return v;
}
for (const lang of ['es-MX','es-419','es-US','es-CO']) {
const v = locales.find(v => v.lang === lang);
if (v) return v;
}
if (locales.length) return locales[0];
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voces.find(v => v.lang === lang);
if (v) return v;
}
return voces[0];
}
function probar(voz) {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(TEXTO);
u.voice = voz;
u.lang = voz.lang;
u.rate = 0.95;
u.pitch = 1.05;
window.speechSynthesis.speak(u);
}
function pintar() {
const todas = window.speechSynthesis.getVoices();
const es = todas.filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
const lista = document.getElementById('lista');
const actual = vozActual(es);
if (!es.length) {
lista.innerHTML = '<div id="vacio">Este equipo no tiene ninguna voz en español instalada. '
+ 'Habría que instalarle una desde la configuración del sistema, '
+ 'o dejar el llamado solo con el pito y el cartel.</div>';
return;
}
// Las locales primero: son las que interesan
es.sort((a, b) => (b.localService - a.localService) || a.name.localeCompare(b.name));
lista.innerHTML = '';
es.forEach((v, i) => {
const fila = document.createElement('div');
fila.className = 'fila' + (v === actual ? ' elegida' : '');
fila.innerHTML =
'<div class="nom">' + v.name + '<small>' + v.lang + '</small></div>'
+ (v.localService ? '<span class="tag local">local</span>'
: '<span class="tag remota">de internet</span>')
+ (v === actual ? '<span class="tag actual">la de ahora</span>' : '');
const b = document.createElement('button');
b.textContent = 'Oír';
b.onclick = () => probar(v);
fila.appendChild(b);
lista.appendChild(fila);
});
}
// getVoices() suele venir vacío en la primera llamada: el navegador las carga aparte
window.speechSynthesis.onvoiceschanged = pintar;
pintar();
setTimeout(pintar, 600);
</script>
</body>
</html>
+61 -7
View File
@@ -11,23 +11,42 @@
*
* Es idempotente: se puede correr las veces que haga falta.
*
* La lectura del histórico tarda más de media hora (son ~230.000 registros de
* un servidor remoto), y mantener la conexión abierta todo ese rato la mataba
* antes de empezar a escribir: "MySQL server has gone away". Por eso el mapa se
* guarda en disco y la escritura usa una conexión nueva. De paso, reintentar
* sale gratis: si el mapa ya está en caché, no se vuelve a leer la tabla.
*
* Uso:
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
* php scripts/backfill_bsuid.php
* php scripts/backfill_bsuid.php --releer (ignora la caché y relee el histórico)
*/
require_once __DIR__ . '/../config/config.php';
$simular = in_array('--simular', $argv, true);
$releer = in_array('--releer', $argv, true);
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
$db = Database::getInstance();
$cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
echo "Leyendo webhook_logs...\n";
$mapa = [];
$sinTelefono = [];
if (!$releer && is_readable($cache)) {
$guardado = json_decode(file_get_contents($cache), true) ?: [];
$mapa = $guardado['mapa'] ?? [];
$sinTelefono = $guardado['sinTelefono'] ?? [];
printf("Mapa tomado de la caché (%s, del %s).\n", basename($cache), date('Y-m-d H:i', filemtime($cache)));
printf("Para releer el histórico: --releer\n\n");
}
if (!$mapa) {
echo "Leyendo webhook_logs (tarda; es una sola vez)...\n";
$db = Database::getInstance();
// Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
// volvía a recorrerla entera y tardaba una eternidad).
@@ -82,24 +101,59 @@ try {
}
$stmt->closeCursor();
} finally {
// Dejar la conexión como estaba: las escrituras siguientes la necesitan con buffer
// Dejar la conexión como estaba
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
}
printf("\n registros revisados: %d\n", $leidos);
printf(" equivalencias encontradas: %d\n", count($mapa));
printf(" personas que llegaron sin teléfono: %d\n\n", count($sinTelefono));
file_put_contents($cache, json_encode(['mapa' => $mapa, 'sinTelefono' => $sinTelefono]));
printf(" mapa guardado en %s\n", $cache);
// Se termina aquí a propósito. Tras media hora de lectura el servidor ya cerró
// esta conexión, y el singleton de Database no sabe reconectar: escribir ahora
// falla con "MySQL server has gone away" a mitad del recorrido, que es justo lo
// que pasó la primera vez. La segunda ejecución toma el mapa de la caché en un
// segundo y escribe con la conexión sana.
echo "\nMapa listo. Ejecute otra vez el comando para aplicarlo.\n";
exit(0);
}
printf(" equivalencias encontradas: %d\n", count($mapa));
printf(" personas que llegaron sin teléfono: %d\n", count($sinTelefono));
// --- 1b. Un solo BSUID por teléfono ---
// Hay números que aparecen con dos identificadores: la línea cambió de dueño
// (reciclada) o la persona se registró de nuevo. Si se recorre el mapa tal cual,
// ambos escriben sobre el mismo usuario y el que queda depende del orden: cada
// ejecución dejaba un valor distinto. Se resuelve antes de tocar la base,
// quedándose con el visto más tarde, y se escribe una sola vez por teléfono.
$porTelefono = [];
$elegido = [];
foreach ($mapa as $b => $t) {
$porTelefono[$t][] = $b;
$elegido[$t] = $b; // el orden del mapa es el de lectura: el último gana
}
$ambiguos = array_filter($porTelefono, fn($bs) => count($bs) > 1);
printf(" teléfonos distintos: %d\n", count($porTelefono));
printf(" teléfonos con más de un BSUID: %d (se toma el más reciente)\n\n", count($ambiguos));
foreach (array_slice($ambiguos, 0, 10, true) as $t => $bs) {
printf(" %s → %s\n", substr($t, 0, -4) . '****', implode(', ', $bs));
}
if (count($ambiguos) > 10) printf(" (y %d más)\n", count($ambiguos) - 10);
if ($ambiguos) echo "\n";
// --- 2. Escribir el BSUID en el usuario que corresponde ---
$db = Database::getInstance();
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
foreach ($mapa as $bsuid => $telefono) {
foreach ($elegido as $telefono => $bsuid) {
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
if (!$u) { $noExisten++; continue; }
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
if (!empty($u['bsuid'])) {
// El BSUID se regenera si la persona cambia de número: gana el más reciente
printf(" aviso: %s tenía %s y ahora %s\n", $telefono, $u['bsuid'], $bsuid);
printf(" aviso: %s tenía %s y queda con %s\n", $telefono, $u['bsuid'], $bsuid);
$conflictos++;
}
if (!$simular) {
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* Verificación de la identidad por BSUID (nombres de usuario de WhatsApp).
* Ejecutar: php test_bsuid.php
*/
require_once __DIR__ . '/../services/WhatsAppService.php';
$fallos = 0;
function chequear($descripcion, $condicion) {
global $fallos;
if ($condicion) { echo " ok $descripcion\n"; }
else { echo " FALLA $descripcion\n"; $fallos++; }
}
// --- 1. Reconocer un BSUID y no confundirlo con un teléfono ---
chequear('reconoce el BSUID real de hoy', WhatsAppService::esBsuid('CO.1761088155094242'));
chequear('reconoce el BSUID largo de Meta', WhatsAppService::esBsuid('US.13491208655302741918'));
chequear('un celular NO es BSUID', !WhatsAppService::esBsuid('573214533764'));
chequear('vacío NO es BSUID', !WhatsAppService::esBsuid(''));
chequear('texto suelto NO es BSUID', !WhatsAppService::esBsuid('CO.abc'));
// --- 2. formatPhoneNumber no debe destrozar el BSUID ---
$svc = (new ReflectionClass('WhatsAppService'))->newInstanceWithoutConstructor();
$fmt = new ReflectionMethod('WhatsAppService', 'formatPhoneNumber');
$fmt->setAccessible(true);
chequear('el BSUID sale intacto',
$fmt->invoke($svc, 'CO.1761088155094242') === 'CO.1761088155094242');
chequear('el celular sin indicativo sigue recibiendo el 57',
$fmt->invoke($svc, '3214533764') === '573214533764');
chequear('el celular con indicativo se conserva',
$fmt->invoke($svc, '573214533764') === '573214533764');
// --- 3. El payload debe usar `recipient` para BSUID y `to` para teléfonos ---
// Se replica la transformación de sendMessage(), que es privada y hace red.
$transformar = function (array $payload) {
if (isset($payload['to']) && WhatsAppService::esBsuid($payload['to'])) {
$payload['recipient'] = $payload['to'];
unset($payload['to']);
}
return $payload;
};
$conBsuid = $transformar(['to' => 'CO.1761088155094242', 'type' => 'text']);
chequear('con BSUID se envía en `recipient`', ($conBsuid['recipient'] ?? null) === 'CO.1761088155094242');
chequear('con BSUID desaparece `to`', !isset($conBsuid['to']));
$conTel = $transformar(['to' => '573214533764', 'type' => 'text']);
chequear('con teléfono se mantiene `to`', ($conTel['to'] ?? null) === '573214533764');
chequear('con teléfono no aparece `recipient`', !isset($conTel['recipient']));
// --- 4. El webhook debe extraer el remitente del payload real que llegó hoy ---
$payloadReal = json_decode('{"messaging_product":"whatsapp",
"contacts":[{"profile":{"name":"con la fe puesta en Dios","username":"Luzmarytosfer"},
"user_id":"CO.1761088155094242"}],
"messages":[{"from_user_id":"CO.1761088155094242","id":"wamid.XXX",
"timestamp":"1786481656","text":{"body":"Buenos dias"},"type":"text"}]}', true);
$m = $payloadReal['messages'][0];
$remitente = $m['from'] ?? ($m['wa_id'] ?? ($m['from_user_id'] ?? null));
chequear('el mensaje que se perdía ahora sí tiene remitente', $remitente === 'CO.1761088155094242');
$nombres = [];
foreach ($payloadReal['contacts'] as $c) {
$n = $c['profile']['name'] ?? null;
if (!$n) continue;
foreach ([$c['wa_id'] ?? null, $c['user_id'] ?? null] as $k) {
if ($k) $nombres[$k] = $n;
}
}
chequear('se rescata el nombre aunque no haya wa_id',
($nombres['CO.1761088155094242'] ?? null) === 'con la fe puesta en Dios');
// --- 5. El identificador debe caber en la columna ---
chequear('el BSUID más largo cabe en varchar(32)', strlen('US.13491208655302741918') <= 32);
// --- 6. Extraer el teléfono del contacto compartido (webhook `contacts` de Meta) ---
$extraerTelefono = function (array $mensaje) {
foreach ($mensaje['contacts'] ?? [] as $c) {
foreach ($c['phones'] ?? [] as $t) {
$cand = $t['wa_id'] ?? ($t['phone'] ?? null);
if ($cand) return preg_replace('/[^0-9]/', '', $cand);
}
}
return null;
};
$porBoton = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
'contacts' => [['origin' => 'contact_request',
'phones' => [['phone' => '+57 321 4533764', 'wa_id' => '573214533764', 'type' => 'CELL']]]]];
chequear('del botón se extrae el teléfono', $extraerTelefono($porBoton) === '573214533764');
// Compartido a mano: puede venir con vcard y sin wa_id
$aMano = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
'contacts' => [['origin' => 'other', 'vcard' => 'BEGIN:VCARD...',
'phones' => [['phone' => '+57 321 453 3764', 'type' => 'CELL']]]]];
chequear('compartido a mano también se extrae', $extraerTelefono($aMano) === '573214533764');
chequear('sin teléfono devuelve nulo', $extraerTelefono(['type' => 'contacts', 'contacts' => [[]]]) === null);
// --- 7. El botón para pedir el contacto debe armarse como exige Meta ---
$pedir = new ReflectionMethod('WhatsAppService', 'pedirContacto');
chequear('pedirContacto existe y es pública', $pedir->isPublic());
$payloadBoton = [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $fmt->invoke($svc, 'CO.1761088155094242'),
'type' => 'interactive',
'interactive' => ['type' => 'request_contact_info',
'body' => ['text' => 'texto'],
'action' => ['name' => 'request_contact_info']],
];
$enviado = $transformar($payloadBoton);
chequear('el botón viaja con `recipient` cuando es BSUID',
($enviado['recipient'] ?? null) === 'CO.1761088155094242' && !isset($enviado['to']));
chequear('el tipo interactivo es el que Meta espera',
$enviado['interactive']['type'] === 'request_contact_info'
&& $enviado['interactive']['action']['name'] === 'request_contact_info');
// --- 8. La ficha del paciente no debe recibir el BSUID como teléfono ---
// Réplica de la decisión de Paciente::obtenerOCrearDesdeWhatsapp()
$fichaDesde = function ($phoneNumber, $nombre = null) {
$sinTelefono = esBsuid($phoneNumber);
return [
'nombre_completo' => $nombre ?? ($sinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $phoneNumber),
'telefono' => $sinTelefono ? null : $phoneNumber,
];
};
$fichaOculta = $fichaDesde('CO.1761088155094242');
chequear('la ficha queda SIN teléfono si es BSUID', $fichaOculta['telefono'] === null);
chequear('y sin el identificador metido en el nombre',
strpos($fichaOculta['nombre_completo'], 'CO.') === false);
$fichaNormal = $fichaDesde('573214533764');
chequear('con teléfono real la ficha lo conserva', $fichaNormal['telefono'] === '573214533764');
chequear('y el nombre por defecto no cambia', $fichaNormal['nombre_completo'] === 'Paciente 573214533764');
// El daño concreto que se evita: normalizarTelefono() dejaba 16 dígitos con
// toda la pinta de un número real dentro de la historia clínica.
$comoQuedaba = preg_replace('/[^0-9+]/', '', 'CO.1761088155094242');
chequear('antes se guardaba un número falso creíble', $comoQuedaba === '1761088155094242');
chequear('y ahora eso ya no llega al campo teléfono', $fichaOculta['telefono'] !== $comoQuedaba);
// --- 9. Solo se pide el contacto a quien hace falta, y una sola vez ---
$decidir = function ($phoneNumber, $pedidoAt) {
if (!esBsuid($phoneNumber)) return 'no_hace_falta';
if (!empty($pedidoAt)) return 'ya_se_pidio';
return 'pedido';
};
chequear('a quien tiene teléfono no se le pide', $decidir('573214533764', null) === 'no_hace_falta');
chequear('a quien lo oculta sí se le pide', $decidir('CO.1761088155094242', null) === 'pedido');
chequear('y no se le insiste una segunda vez',
$decidir('CO.1761088155094242', '2026-08-11 16:00:00') === 'ya_se_pidio');
// --- 10. La comprobación vive en un solo sitio ---
chequear('esBsuid() global y el del servicio coinciden',
esBsuid('CO.1761088155094242') === WhatsAppService::esBsuid('CO.1761088155094242')
&& esBsuid('573214533764') === WhatsAppService::esBsuid('573214533764'));
echo $fallos === 0 ? "\nTodo correcto.\n" : "\n$fallos verificaciones fallaron.\n";
exit($fallos === 0 ? 0 : 1);
+3 -2
View File
@@ -917,11 +917,12 @@ class WhatsAppService
/**
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
* Formato de Meta: código de país, punto y dígitos. Ej.: "CO.1761088155094242".
* La definición vive en config.php, que es donde la ven también las clases del
* laboratorio; aquí solo se expone para quien ya tiene el servicio a la mano.
*/
public static function esBsuid($destinatario)
{
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $destinatario);
return esBsuid($destinatario);
}
/**