Files
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

320 lines
12 KiB
PHP

<?php
/**
* Paciente — Gestión de pacientes del módulo administrativo de laboratorio.
* Extiende la información del contacto WhatsApp (users.id).
*/
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;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// CRUD
// ─────────────────────────────────────────────────────────────────────────
/**
* Lista paginada de pacientes con búsqueda.
*/
public function listar(
string $busqueda = '',
int $pagina = 1,
int $porPagina = 30,
string $origen = ''
): array {
$offset = ($pagina - 1) * $porPagina;
$like = "%$busqueda%";
$conditions = [];
$params = [];
if ($busqueda) {
$conditions[] = "(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR p.telefono LIKE ? OR p.email LIKE ?)";
$params = [$like, $like, $like, $like];
}
if ($origen) {
$conditions[] = "p.origen = ?";
$params[] = $origen;
}
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
$total = $this->db->fetch(
"SELECT COUNT(*) AS n FROM lab_pacientes p $where",
$params
)['n'] ?? 0;
$rows = $this->db->fetchAll("
SELECT
p.*,
u.phone_number,
u.name AS whatsapp_name,
(SELECT COUNT(*) FROM lab_ordenes_medicas o WHERE o.paciente_id = p.id) AS total_ordenes
FROM lab_pacientes p
LEFT JOIN users u ON u.id = p.user_id
$where
ORDER BY p.created_at DESC, p.nombre_completo ASC
LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset]));
return [
'data' => $rows,
'total' => (int)$total,
'pagina' => $pagina,
'por_pagina' => $porPagina,
'paginas' => (int)ceil($total / $porPagina),
];
}
public function stats(): array {
return $this->db->fetch("
SELECT
COUNT(*) AS total,
SUM(origen = 'lab') AS importados,
SUM(origen = 'manual') AS manuales,
SUM(origen = 'whatsapp') AS whatsapp,
SUM(user_id IS NOT NULL) AS con_wa
FROM lab_pacientes
WHERE is_active = 1
") ?: ['total' => 0, 'importados' => 0, 'manuales' => 0, 'whatsapp' => 0, 'con_wa' => 0];
}
/**
* Un paciente por ID (con datos del usuario WhatsApp).
*/
public function obtener(int $id): ?array {
return $this->db->fetch("
SELECT
p.*,
u.phone_number,
u.name AS whatsapp_name,
u.status AS whatsapp_status,
u.created_at AS whatsapp_desde
FROM lab_pacientes p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.id = ?
", [$id]) ?: null;
}
/**
* Buscar por número de documento.
*/
public function porDocumento(string $documento): ?array {
return $this->db->fetch(
'SELECT * FROM lab_pacientes WHERE numero_documento = ?',
[$documento]
) ?: null;
}
/**
* Buscar por user_id (teléfono WhatsApp).
*/
public function porUserId(int $userId): ?array {
return $this->db->fetch(
'SELECT * FROM lab_pacientes WHERE user_id = ?',
[$userId]
) ?: null;
}
/**
* Crear paciente nuevo.
*
* @param array $datos Campos del formulario
* @param int|null $adminId Quien lo crea (para trazabilidad)
* @return int ID del paciente creado
*/
public function crear(array $datos, ?int $adminId = null): int {
$campos = $this->filtrarCampos($datos);
// Validar documento duplicado antes de insertar
if (!empty($campos['numero_documento'])) {
$existente = $this->db->fetch(
'SELECT id, nombre_completo FROM lab_pacientes WHERE numero_documento = ?',
[$campos['numero_documento']]
);
if ($existente) {
throw new Exception(
'Ya existe un paciente registrado con ese número de documento: ' .
$existente['nombre_completo']
);
}
}
$id = $this->db->insert('lab_pacientes', $campos);
$this->log->registrar($adminId, 'pacientes', 'crear', $id, $campos);
return $id;
}
/**
* Actualizar paciente existente.
*/
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
$campos = $this->filtrarCampos($datos);
// Validar que el documento no pertenezca a otro paciente
if (!empty($campos['numero_documento'])) {
$existente = $this->db->fetch(
'SELECT id, nombre_completo FROM lab_pacientes WHERE numero_documento = ? AND id != ?',
[$campos['numero_documento'], $id]
);
if ($existente) {
throw new Exception(
'Ese número de documento ya está registrado para: ' .
$existente['nombre_completo']
);
}
}
// Registrar antes y después de lo que realmente cambia. Guardar solo el
// valor nuevo impide reconstruir el dato anterior si la corrección
// resultó equivocada, que es justo cuando hace falta consultarlo.
$antes = $this->db->fetch('SELECT * FROM lab_pacientes WHERE id = ?', [$id]) ?: [];
$cambios = [];
foreach ($campos as $campo => $nuevo) {
$previo = $antes[$campo] ?? null;
if ((string)$previo !== (string)$nuevo) {
$cambios[$campo] = ['antes' => $previo, 'despues' => $nuevo];
}
}
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
if ($cambios) {
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $cambios);
}
return $ok > 0;
}
/**
* Desactivar (soft-delete).
*/
public function desactivar(int $id, ?int $adminId = null): bool {
$ok = $this->db->update('lab_pacientes', ['is_active' => 0], 'id = ?', [$id]);
$this->log->registrar($adminId, 'pacientes', 'desactivar', $id);
return $ok > 0;
}
/**
* Vincular paciente con un usuario de WhatsApp.
*/
public function vincularUsuario(int $pacienteId, int $userId, ?int $adminId = null): bool {
$ok = $this->db->update('lab_pacientes', ['user_id' => $userId], 'id = ?', [$pacienteId]);
$this->log->registrar($adminId, 'pacientes', 'vincular_whatsapp', $pacienteId, [
'user_id' => $userId,
]);
return $ok > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// Historial
// ─────────────────────────────────────────────────────────────────────────
/**
* Órdenes médicas del paciente.
*/
public function ordenes(int $pacienteId, int $limit = 20): array {
return $this->db->fetchAll("
SELECT
o.*,
au.full_name AS autorizada_por_nombre
FROM lab_ordenes_medicas o
LEFT JOIN admin_users au ON au.id = o.autorizada_por
WHERE o.paciente_id = ?
ORDER BY o.created_at DESC
LIMIT ?
", [$pacienteId, $limit]);
}
/**
* Historial de actividad de trazabilidad del paciente.
*/
public function actividad(int $pacienteId): array {
return $this->log->porEntidad('pacientes', $pacienteId);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Crea automáticamente un paciente a partir de un users.id (whatsapp)
* si aún no existe. Devuelve el id del paciente.
*/
public function obtenerOCrearDesdeWhatsapp(int $userId): int {
$existente = $this->porUserId($userId);
if ($existente) {
return $existente['id'];
}
$user = $this->db->fetch(
'SELECT * FROM users WHERE id = ?',
[$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']
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
]);
}
private function filtrarCampos(array $datos): array {
$permitidos = [
'user_id', 'numero_documento', 'tipo_documento',
'nombre_completo', 'telefono', 'email',
'fecha_nacimiento', 'genero', 'direccion',
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'origen',
];
$campos = array_intersect_key($datos, array_flip($permitidos));
if (!empty($campos['telefono'])) {
$campos['telefono'] = $this->normalizarTelefono($campos['telefono']);
}
return $campos;
}
/**
* Normaliza un número de teléfono colombiano al formato 57XXXXXXXXXX.
* - Elimina espacios, guiones y paréntesis.
* - Si empieza por +57 → quita el +.
* - Si tiene 10 dígitos empezando por 3 → antepone 57.
* - Cualquier otro caso lo devuelve limpio de caracteres no numéricos.
*/
private function normalizarTelefono(string $tel): string {
$tel = preg_replace('/[^0-9+]/', '', $tel);
if (str_starts_with($tel, '+')) {
$tel = ltrim($tel, '+');
}
// Sin prefijo (10 dígitos empezando por 3) → asume Colombia 57
if (strlen($tel) === 10 && str_starts_with($tel, '3')) {
$tel = '57' . $tel;
}
// Si ya trae prefijo de 2 dígitos (57xx... o 58xx...) lo respetamos
return $tel;
}
}