Compare commits
17
Commits
+6
-1
@@ -36,7 +36,12 @@ RUN apk add --no-cache curl-dev \
|
|||||||
opcache
|
opcache
|
||||||
|
|
||||||
# Instalar Redis extension (versión fija para cache reproducible)
|
# 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
|
# Instalar Composer
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
@@ -21,6 +21,53 @@ requireAuthentication();
|
|||||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||||
$db = Database::getInstance();
|
$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 ─────────────────────────────────────────────────────
|
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
|||||||
$pacienteRepo = new Paciente();
|
$pacienteRepo = new Paciente();
|
||||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
$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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
require_once __DIR__ . '/../../classes/Database.php';
|
require_once __DIR__ . '/../../classes/Database.php';
|
||||||
require_once __DIR__ . '/ActividadAdmin.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 {
|
class Paciente {
|
||||||
|
|
||||||
private Database $db;
|
private Database $db;
|
||||||
@@ -258,10 +267,19 @@ class Paciente {
|
|||||||
[$userId]
|
[$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([
|
return $this->crear([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
'nombre_completo'=> $user['name']
|
||||||
'telefono' => $user['phone_number'] ?? null,
|
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||||
|
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Limpia el cache estático de configuraciones
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ class Router
|
|||||||
private const PUBLIC_ROUTES = [
|
private const PUBLIC_ROUTES = [
|
||||||
'turnero/display',
|
'turnero/display',
|
||||||
'turnero/kiosko',
|
'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 */
|
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||||
|
|||||||
+35
-3
@@ -472,6 +472,28 @@ function fmtFecha(str) {
|
|||||||
return str.slice(0, 10).split('-').reverse().join('/');
|
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) {
|
async function cargarLista(pag = 1) {
|
||||||
paginaActual = pag;
|
paginaActual = pag;
|
||||||
const busq = document.getElementById('buscador').value.trim();
|
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>` : ''}
|
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></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.email||'—')}</td>
|
||||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
|||||||
document.getElementById('detail-body').innerHTML = `
|
document.getElementById('detail-body').innerHTML = `
|
||||||
<dl class="row small mb-3">
|
<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">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">Teléfono</dt><dd class="col-7">${
|
||||||
<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>
|
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">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>
|
<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>` : ''}
|
${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
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_recepcion'
|
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"
|
ORDER BY t.llamado_recepcion_at DESC"
|
||||||
);
|
);
|
||||||
$stmtRec->execute([$sesionId]);
|
$stmtRec->execute([$sesionId]);
|
||||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
|||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_servicio'
|
AND t.estado = 'en_servicio'
|
||||||
AND t.lugar_destino_id = ?
|
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
|
ORDER BY t.llamado_lugar_at DESC
|
||||||
LIMIT 1"
|
LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
]);
|
||||||
@@ -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'] ?>"
|
<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">
|
style="border:1px solid #e2e8f0;border-radius:10px">
|
||||||
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
|
<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'): ?>
|
<?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: ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
<div class="flex-grow-1">
|
<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>
|
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
|
||||||
|
|||||||
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panel publicitario (se activa con ?video=URL) -->
|
<!-- 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
|
<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="">
|
<source id="video-src" src="">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
|
|||||||
const body = document.getElementById('display-body');
|
const body = document.getElementById('display-body');
|
||||||
src.src = videoUrl;
|
src.src = videoUrl;
|
||||||
vid.load();
|
vid.load();
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
|
||||||
// 3 columnas: turno | video | cola
|
// 3 columnas: turno | video | cola
|
||||||
body.style.gridTemplateColumns = '1fr 320px 280px';
|
body.style.gridTemplateColumns = '1fr 320px 280px';
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
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 {
|
.pg-body.has-video {
|
||||||
grid-template-columns: 1fr 29%;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
.pg-body.has-video .turno-activo { display: none; }
|
.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); }
|
.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 {
|
.reel-wrap {
|
||||||
display: none;
|
display: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-width: 50vw;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 8px 8px 8px 0;
|
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 {
|
.reel-wrap .reel-inner {
|
||||||
width: 100%; height: 100%;
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
max-width: 100%;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
||||||
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
<div class="dot"></div>
|
<div class="dot"></div>
|
||||||
</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
|
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
|||||||
<div class="hd-right">
|
<div class="hd-right">
|
||||||
<div class="live-dot"></div>
|
<div class="live-dot"></div>
|
||||||
<div class="reloj" id="reloj">--:--:--</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>
|
<i class="fas fa-volume-mute"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
||||||
@@ -499,14 +516,22 @@ function tick() {
|
|||||||
tick(); setInterval(tick, 1000);
|
tick(); setInterval(tick, 1000);
|
||||||
|
|
||||||
/* ── Audio ── */
|
/* ── 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 {
|
try {
|
||||||
if (!audioCtx) {
|
if (!audioCtx) {
|
||||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
} else if (audioCtx.state === 'suspended') {
|
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
|
||||||
audioCtx.resume();
|
// 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 g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
|
||||||
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
||||||
@@ -516,13 +541,39 @@ function activarSonido() {
|
|||||||
localStorage.setItem('turneroSonido', '1');
|
localStorage.setItem('turneroSonido', '1');
|
||||||
const btn = document.getElementById('btn-sonido');
|
const btn = document.getElementById('btn-sonido');
|
||||||
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
||||||
const banner = document.getElementById('sonido-banner');
|
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
|
||||||
if (banner) banner.style.display = 'none';
|
// pantalla no tiene por qué pitar cada vez que se recarga.
|
||||||
setTimeout(playBeep, 100);
|
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() {
|
function playBeep() {
|
||||||
if (!sonidoActivo || !audioCtx) return;
|
if (!sonidoActivo || !audioCtx) return;
|
||||||
@@ -544,17 +595,41 @@ function playBeep() {
|
|||||||
|
|
||||||
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
||||||
let _vozES = null;
|
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() {
|
function getVozES() {
|
||||||
if (_vozES) return _vozES;
|
if (_vozES) return _vozES;
|
||||||
const voices = window.speechSynthesis.getVoices();
|
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||||
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
|
if (!voces.length) return null;
|
||||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
|
||||||
const v = voices.find(v => v.lang === lang);
|
const locales = voces.filter(v => v.localService);
|
||||||
if (v) { _vozES = v; return v; }
|
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'));
|
// 2. Cualquier otra local latinoamericana
|
||||||
if (v) { _vozES = v; }
|
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
|
||||||
return _vozES;
|
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) {
|
if ('speechSynthesis' in window) {
|
||||||
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
||||||
@@ -568,36 +643,90 @@ if ('speechSynthesis' in window) {
|
|||||||
window.speechSynthesis.resume();
|
window.speechSynthesis.resume();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
// Auto-restore sonido si estaba activo antes de un reload
|
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
|
||||||
if (localStorage.getItem('turneroSonido') === '1') {
|
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
|
||||||
const banner = document.getElementById('sonido-banner');
|
|
||||||
if (banner) banner.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
playBeep();
|
||||||
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
|
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
|
||||||
|
|
||||||
const letras = codigo.split('').join(' ');
|
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}`;
|
let texto = `Turno ${letras}`;
|
||||||
if (nomVoz) texto += `, ${nomVoz}`;
|
if (nomVoz) texto += `, ${nomVoz}`;
|
||||||
texto += `, pase a ${destino}`;
|
texto += `, pase a ${destino}`;
|
||||||
|
|
||||||
|
let avisado = false;
|
||||||
|
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
|
||||||
|
|
||||||
|
const decir = (esReintento) => {
|
||||||
const utt = new SpeechSynthesisUtterance(texto);
|
const utt = new SpeechSynthesisUtterance(texto);
|
||||||
utt.lang = 'es-CO';
|
utt.lang = 'es-CO';
|
||||||
const voz = getVozES();
|
|
||||||
if (voz) utt.voice = voz;
|
|
||||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
||||||
|
|
||||||
// Con el sintetizador libre se habla de inmediato. Solo cuando hay algo en
|
let arranco = false;
|
||||||
// curso hace falta cancelar y esperar un instante: Chrome ignora un speak()
|
utt.addEventListener('start', () => { arranco = true; });
|
||||||
// encadenado a un cancel() en el mismo ciclo.
|
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) {
|
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
||||||
window.speechSynthesis.cancel();
|
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 ── */
|
/* ── Fullscreen ── */
|
||||||
@@ -642,8 +771,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
|||||||
|
|
||||||
ov.classList.add('visible');
|
ov.classList.add('visible');
|
||||||
|
|
||||||
const utt = anunciarTurno(codigo, destino, paciente);
|
|
||||||
|
|
||||||
bar.style.transition = 'none';
|
bar.style.transition = 'none';
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
@@ -668,11 +795,15 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
|||||||
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (utt) {
|
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
|
||||||
utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400));
|
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
|
||||||
utt.addEventListener('error', cerrarCuandoToque);
|
// pantallas donde el navegador bloquea el audio.
|
||||||
// Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo
|
const intentoHablar = anunciarTurno(codigo, destino, paciente,
|
||||||
setTimeout(dismiss, 25000);
|
() => setTimeout(cerrarCuandoToque, 400));
|
||||||
|
|
||||||
|
if (intentoHablar) {
|
||||||
|
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
|
||||||
|
setTimeout(dismiss, 20000);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(dismiss, MIN_VISIBLE);
|
setTimeout(dismiss, MIN_VISIBLE);
|
||||||
}
|
}
|
||||||
@@ -727,18 +858,16 @@ function renderSnapshot(snap) {
|
|||||||
});
|
});
|
||||||
lastSeenKeys = currentKeys;
|
lastSeenKeys = currentKeys;
|
||||||
|
|
||||||
if (_firstLoad) {
|
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
|
||||||
_firstLoad = false;
|
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
|
||||||
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
|
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
|
||||||
if (sorted.length > 0) {
|
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
|
||||||
const t = sorted[0];
|
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
|
||||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
//
|
||||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
|
||||||
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
|
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
|
||||||
announcedCalls.add(key);
|
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
|
||||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
if (_firstLoad) _firstLoad = false;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anuncioNuevo) renderLlamados();
|
if (anuncioNuevo) renderLlamados();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1355,9 +1355,11 @@ async function seleccionarSinLlamar(turnoId) {
|
|||||||
mostrarCabeceraTurno(t);
|
mostrarCabeceraTurno(t);
|
||||||
await cargarFichaSolicitud(t.id);
|
await cargarFichaSolicitud(t.id);
|
||||||
clearInterval(pollingConsentId);
|
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);
|
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
|
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||||
cargarFormEmbebido(t.id);
|
cargarFormEmbebido(t.id);
|
||||||
mostrarFichaMobile();
|
mostrarFichaMobile();
|
||||||
}
|
}
|
||||||
@@ -1400,9 +1402,8 @@ async function abrirFicha(turno) {
|
|||||||
mostrarCabeceraTurno(turno);
|
mostrarCabeceraTurno(turno);
|
||||||
await cargarFichaSolicitud(turno.id);
|
await cargarFichaSolicitud(turno.id);
|
||||||
clearInterval(pollingConsentId);
|
clearInterval(pollingConsentId);
|
||||||
if (!_esSoloEntrega(turno))
|
|
||||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
|
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||||
cargarFormEmbebido(turno.id);
|
cargarFormEmbebido(turno.id);
|
||||||
mostrarFichaMobile();
|
mostrarFichaMobile();
|
||||||
}
|
}
|
||||||
@@ -1468,10 +1469,15 @@ async function cargarFichaSolicitud(turnoId) {
|
|||||||
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
||||||
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
|
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 secConsent = document.getElementById('sec-consent');
|
||||||
const secForm = document.getElementById('sec-form-embebido');
|
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');
|
if (secForm) secForm.classList.add('d-none');
|
||||||
|
|
||||||
// Embarazada
|
// Embarazada
|
||||||
@@ -2230,7 +2236,7 @@ function resetFicha() {
|
|||||||
_muestrasActivas = [];
|
_muestrasActivas = [];
|
||||||
const secMuestras = document.getElementById('sec-muestras');
|
const secMuestras = document.getElementById('sec-muestras');
|
||||||
if (secMuestras) secMuestras.classList.add('d-none');
|
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('sec-consent')?.classList.remove('d-none');
|
||||||
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
|
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
|
||||||
document.getElementById('ficha-orden').classList.add('d-none');
|
document.getElementById('ficha-orden').classList.add('d-none');
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -11,23 +11,42 @@
|
|||||||
*
|
*
|
||||||
* Es idempotente: se puede correr las veces que haga falta.
|
* 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:
|
* Uso:
|
||||||
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
|
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
|
||||||
* php scripts/backfill_bsuid.php
|
* 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';
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
$simular = in_array('--simular', $argv, true);
|
$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";
|
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 ---
|
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
|
||||||
echo "Leyendo webhook_logs...\n";
|
|
||||||
$mapa = [];
|
$mapa = [];
|
||||||
$sinTelefono = [];
|
$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
|
// 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
|
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
|
||||||
// volvía a recorrerla entera y tardaba una eternidad).
|
// volvía a recorrerla entera y tardaba una eternidad).
|
||||||
@@ -82,24 +101,59 @@ try {
|
|||||||
}
|
}
|
||||||
$stmt->closeCursor();
|
$stmt->closeCursor();
|
||||||
} finally {
|
} 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);
|
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
|
||||||
}
|
}
|
||||||
|
|
||||||
printf("\n registros revisados: %d\n", $leidos);
|
printf("\n registros revisados: %d\n", $leidos);
|
||||||
printf(" equivalencias encontradas: %d\n", count($mapa));
|
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 ---
|
// --- 2. Escribir el BSUID en el usuario que corresponde ---
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
|
$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]);
|
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
|
||||||
if (!$u) { $noExisten++; continue; }
|
if (!$u) { $noExisten++; continue; }
|
||||||
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
|
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
|
||||||
if (!empty($u['bsuid'])) {
|
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 queda con %s\n", $telefono, $u['bsuid'], $bsuid);
|
||||||
printf(" aviso: %s tenía %s y ahora %s\n", $telefono, $u['bsuid'], $bsuid);
|
|
||||||
$conflictos++;
|
$conflictos++;
|
||||||
}
|
}
|
||||||
if (!$simular) {
|
if (!$simular) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -917,11 +917,12 @@ class WhatsAppService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
|
* ¿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)
|
public static function esBsuid($destinatario)
|
||||||
{
|
{
|
||||||
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $destinatario);
|
return esBsuid($destinatario);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user