This commit is contained in:
Lizandro Guarnizo
2026-03-10 18:42:51 -05:00
parent b57fc16ab3
commit db469b2134
98 changed files with 16365 additions and 303 deletions
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* ActividadAdmin — Trazabilidad del módulo administrativo de laboratorio
* Registra toda acción realizada sobre las entidades del módulo.
*/
require_once __DIR__ . '/../../classes/Database.php';
class ActividadAdmin {
private Database $db;
public function __construct() {
$this->db = Database::getInstance();
}
// ─────────────────────────────────────────────────────────────────────────
// Registro de actividad
// ─────────────────────────────────────────────────────────────────────────
/**
* Registra una acción en el log.
*
* @param int|null $adminId admin_users.id
* @param string $modulo pacientes|ordenes|domicilios|enfermeras|asignaciones
* @param string $accion crear|editar|eliminar|autorizar|rechazar|asignar...
* @param int|null $entidadId ID del registro afectado
* @param mixed $detalle Texto o array (se serializa como JSON)
*/
public function registrar(
?int $adminId,
string $modulo,
string $accion,
?int $entidadId = null,
$detalle = null
): int {
$adminNombre = null;
if ($adminId) {
$row = $this->db->fetch(
'SELECT full_name FROM admin_users WHERE id = ?',
[$adminId]
);
$adminNombre = $row['full_name'] ?? null;
}
$detalleStr = is_array($detalle) || is_object($detalle)
? json_encode($detalle, JSON_UNESCAPED_UNICODE)
: (string)($detalle ?? '');
return $this->db->insert('lab_actividad_admin', [
'admin_id' => $adminId,
'admin_nombre'=> $adminNombre,
'modulo' => $modulo,
'accion' => $accion,
'entidad_id' => $entidadId,
'detalle' => $detalleStr,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Consultas
// ─────────────────────────────────────────────────────────────────────────
/**
* Actividad reciente del módulo.
*
* @param int $limit
* @param string $modulo Filtro opcional por módulo
*/
public function reciente(int $limit = 50, string $modulo = ''): array {
$where = $modulo ? 'WHERE a.modulo = ?' : '';
$params = $modulo ? [$modulo] : [];
$params[] = $limit;
return $this->db->fetchAll("
SELECT
a.*,
u.username AS admin_username
FROM lab_actividad_admin a
LEFT JOIN admin_users u ON u.id = a.admin_id
$where
ORDER BY a.created_at DESC
LIMIT ?
", $params);
}
/**
* Actividad de un administrador específico.
*/
public function porAdmin(int $adminId, int $limit = 100): array {
return $this->db->fetchAll("
SELECT * FROM lab_actividad_admin
WHERE admin_id = ?
ORDER BY created_at DESC
LIMIT ?
", [$adminId, $limit]);
}
/**
* Actividad sobre una entidad concreta.
*/
public function porEntidad(string $modulo, int $entidadId): array {
return $this->db->fetchAll("
SELECT
a.*,
u.username AS admin_username
FROM lab_actividad_admin a
LEFT JOIN admin_users u ON u.id = a.admin_id
WHERE a.modulo = ? AND a.entidad_id = ?
ORDER BY a.created_at ASC
", [$modulo, $entidadId]);
}
/**
* Estadísticas de actividad por módulo en un rango de fechas.
*/
public function estadisticas(string $desde, string $hasta): array {
return $this->db->fetchAll("
SELECT
modulo,
accion,
COUNT(*) AS total
FROM lab_actividad_admin
WHERE DATE(created_at) BETWEEN ? AND ?
GROUP BY modulo, accion
ORDER BY modulo, total DESC
", [$desde, $hasta]);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* Asignacion — Asignación de enfermera a un domicilio.
* Solo puede haber una asignación activa por domicilio (UNIQUE KEY).
*/
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
class Asignacion {
private Database $db;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// CRUD
// ─────────────────────────────────────────────────────────────────────────
/**
* Obtener asignación por ID.
*/
public function obtener(int $id): ?array {
return $this->db->fetch("
SELECT
a.*,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
d.fecha_programada,
d.hora_programada,
d.estado AS domicilio_estado,
d.direccion,
p.nombre_completo AS paciente_nombre
FROM lab_asignaciones a
JOIN lab_enfermeras e ON e.id = a.enfermera_id
JOIN lab_domicilios d ON d.id = a.domicilio_id
JOIN lab_pacientes p ON p.id = d.paciente_id
WHERE a.id = ?
", [$id]) ?: null;
}
/**
* Asignar (o reasignar) enfermera a un domicilio.
* - Si ya existía asignación la reemplaza.
*/
public function asignar(
int $domicilioId,
int $enfermeraId,
?int $adminId = null,
string $notas = ''
): int {
// ¿Ya existe?
$existente = $this->db->fetch(
'SELECT id FROM lab_asignaciones WHERE domicilio_id = ?',
[$domicilioId]
);
if ($existente) {
$this->db->update('lab_asignaciones', [
'enfermera_id' => $enfermeraId,
'asignada_por' => $adminId,
'estado' => 'asignada',
'notas' => $notas,
], 'id = ?', [$existente['id']]);
$id = $existente['id'];
$accion = 'reasignar';
} else {
$id = $this->db->insert('lab_asignaciones', [
'domicilio_id' => $domicilioId,
'enfermera_id' => $enfermeraId,
'asignada_por' => $adminId,
'estado' => 'asignada',
'notas' => $notas,
]);
$accion = 'asignar';
}
$this->log->registrar($adminId, 'asignaciones', $accion, $id, [
'domicilio_id' => $domicilioId,
'enfermera_id' => $enfermeraId,
]);
return $id;
}
/**
* Liberar asignación (sin eliminar, cambia estado a 'liberada').
*/
public function liberar(int $id, ?int $adminId = null, string $motivo = ''): bool {
$ok = $this->db->update('lab_asignaciones', ['estado' => 'liberada'], 'id = ?', [$id]);
$this->log->registrar($adminId, 'asignaciones', 'liberar', $id, ['motivo' => $motivo]);
return $ok > 0;
}
/**
* Marcar asignación como completada.
*/
public function completar(int $id, ?int $adminId = null): bool {
$ok = $this->db->update('lab_asignaciones', ['estado' => 'completada'], 'id = ?', [$id]);
$this->log->registrar($adminId, 'asignaciones', 'completar', $id);
return $ok > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// Consultas
// ─────────────────────────────────────────────────────────────────────────
/**
* Asignaciones de una fecha (para vista de agenda general).
*/
public function porFecha(string $fecha = ''): array {
$fecha = $fecha ?: date('Y-m-d');
return $this->db->fetchAll("
SELECT
a.*,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
d.hora_programada,
d.estado AS domicilio_estado,
d.direccion,
d.barrio,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono
FROM lab_asignaciones a
JOIN lab_enfermeras e ON e.id = a.enfermera_id
JOIN lab_domicilios d ON d.id = a.domicilio_id
JOIN lab_pacientes p ON p.id = d.paciente_id
WHERE d.fecha_programada = ?
ORDER BY e.nombre_completo, d.hora_programada ASC
", [$fecha]);
}
/**
* Carga actual del día: cuántos domicilios tiene cada enfermera hoy.
*/
public function cargaHoy(): array {
return $this->db->fetchAll("
SELECT
e.id,
e.nombre_completo,
e.telefono,
COUNT(a.id) AS total_asignaciones,
SUM(d.estado = 'completado') AS completados
FROM lab_enfermeras e
JOIN lab_asignaciones a ON a.enfermera_id = e.id
JOIN lab_domicilios d ON d.id = a.domicilio_id
WHERE d.fecha_programada = CURDATE()
AND a.estado NOT IN ('liberada')
GROUP BY e.id
ORDER BY total_asignaciones DESC
");
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
/**
* Domicilio — Gestión de servicios a domicilio del laboratorio.
*
* Flujo de estados:
* programado → confirmado → en_camino → en_domicilio → completado
* └→ cancelado / reprogramado
*/
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
class Domicilio {
const ESTADOS = [
'programado',
'confirmado',
'en_camino',
'en_domicilio',
'completado',
'cancelado',
'reprogramado',
];
private Database $db;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// CRUD
// ─────────────────────────────────────────────────────────────────────────
/**
* Lista de domicilios con filtros.
*
* @param array $filtros fecha, estado, enfermera_id, paciente_id
*/
public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array {
$wheres = [];
$params = [];
if (!empty($filtros['fecha'])) {
$wheres[] = 'd.fecha_programada = ?';
$params[] = $filtros['fecha'];
}
if (!empty($filtros['desde'])) {
$wheres[] = 'd.fecha_programada >= ?';
$params[] = $filtros['desde'];
}
if (!empty($filtros['hasta'])) {
$wheres[] = 'd.fecha_programada <= ?';
$params[] = $filtros['hasta'];
}
if (!empty($filtros['estado'])) {
$wheres[] = 'd.estado = ?';
$params[] = $filtros['estado'];
}
if (!empty($filtros['enfermera_id'])) {
$wheres[] = 'a.enfermera_id = ?';
$params[] = $filtros['enfermera_id'];
}
if (!empty($filtros['paciente_id'])) {
$wheres[] = 'd.paciente_id = ?';
$params[] = $filtros['paciente_id'];
}
$where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : '';
$offset = ($pagina - 1) * $porPagina;
$total = $this->db->fetch(
"SELECT COUNT(*) AS n
FROM lab_domicilios d
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
$where",
$params
)['n'] ?? 0;
$rows = $this->db->fetchAll("
SELECT
d.*,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
a.id AS asignacion_id,
a.estado AS asignacion_estado
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
$where
ORDER BY d.fecha_programada ASC, d.hora_programada ASC
LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset]));
return [
'data' => $rows,
'total' => (int)$total,
'pagina' => $pagina,
'por_pagina' => $porPagina,
'paginas' => (int)ceil($total / $porPagina),
];
}
/**
* Domicilio por ID con todos los datos relacionados.
*/
public function obtener(int $id): ?array {
return $this->db->fetch("
SELECT
d.*,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono,
p.direccion AS paciente_direccion,
o.examenes_solicitados,
o.indicaciones AS indicaciones_orden,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
a.id AS asignacion_id,
a.estado AS asignacion_estado,
adm.full_name AS creado_por_nombre
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_ordenes_medicas o ON o.id = d.orden_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
LEFT JOIN admin_users adm ON adm.id = d.creado_por
WHERE d.id = ?
", [$id]) ?: null;
}
/**
* Crear nuevo domicilio.
*/
public function crear(array $datos, ?int $adminId = null): int {
$campos = $this->filtrarCampos($datos);
$campos['creado_por'] = $adminId;
// Actualizar orden médica si aplica
if (!empty($campos['orden_id'])) {
$this->db->update(
'lab_ordenes_medicas',
['estado' => 'en_domicilio'],
'id = ?', [$campos['orden_id']]
);
}
$id = (int)$this->db->insert('lab_domicilios', $campos);
$this->log->registrar($adminId, 'domicilios', 'crear', $id, $campos);
return $id;
}
/**
* Actualizar domicilio.
*/
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
$campos = $this->filtrarCampos($datos);
$ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'domicilios', 'editar', $id, $campos);
return $ok > 0;
}
/**
* Cambiar estado del domicilio.
*/
public function cambiarEstado(
int $id,
string $nuevoEstado,
?int $adminId,
array $extras = []
): bool {
if (!in_array($nuevoEstado, self::ESTADOS)) {
throw new InvalidArgumentException("Estado inválido: $nuevoEstado");
}
$campos = array_merge(['estado' => $nuevoEstado], $extras);
$ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'domicilios', $nuevoEstado, $id, $extras);
return $ok > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// Agenda del día
// ─────────────────────────────────────────────────────────────────────────
/**
* Todos los domicilios de hoy (o la fecha indicada).
*/
public function agendaDia(string $fecha = ''): array {
$fecha = $fecha ?: date('Y-m-d');
return $this->listar(['fecha' => $fecha], 1, 200)['data'];
}
/**
* Domicilios sin enfermera asignada.
*/
public function sinAsignar(string $fecha = ''): array {
$fecha = $fecha ?: date('Y-m-d');
return $this->db->fetchAll("
SELECT
d.*,
p.nombre_completo AS paciente_nombre
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
WHERE d.fecha_programada = ?
AND a.id IS NULL
AND d.estado NOT IN ('cancelado','completado')
ORDER BY d.hora_programada ASC
", [$fecha]);
}
/**
* Estadísticas: conteo de domicilios por estado hoy.
*/
public function estadisticasHoy(): array {
$rows = $this->db->fetchAll("
SELECT estado, COUNT(*) AS total
FROM lab_domicilios
WHERE fecha_programada = CURDATE()
GROUP BY estado
");
$result = array_fill_keys(self::ESTADOS, 0);
foreach ($rows as $row) {
$result[$row['estado']] = (int)$row['total'];
}
return $result;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function filtrarCampos(array $datos): array {
$permitidos = [
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio',
'indicaciones_dir', 'fecha_programada', 'hora_programada',
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
'estado', 'motivo_cancelacion',
'fecha_reprogramada', 'hora_llegada', 'hora_salida',
'observaciones', 'muestras_tomadas', 'notas_admin',
'seguro_nombre', 'autorizacion', 'copago_laboratorio',
'valor_domicilio', 'valor_copago',
];
$campos = array_intersect_key($datos, array_flip($permitidos));
// Campos FK que deben ser NULL cuando vienen vacíos o cero
foreach (['orden_id', 'paciente_id'] as $fk) {
if (array_key_exists($fk, $campos) && ($campos[$fk] === '' || $campos[$fk] === '0' || $campos[$fk] === 0)) {
$campos[$fk] = null;
}
}
return $campos;
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* Enfermera — Gestión del personal de enfermería (domicilios).
*/
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
class Enfermera {
private Database $db;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// CRUD
// ─────────────────────────────────────────────────────────────────────────
/**
* Lista de enfermeras activas/todas.
*/
public function listar(bool $soloActivas = true): array {
$where = $soloActivas ? 'WHERE is_active = 1' : '';
return $this->db->fetchAll("
SELECT
e.*,
(SELECT COUNT(*) FROM lab_asignaciones a
JOIN lab_domicilios d ON d.id = a.domicilio_id
WHERE a.enfermera_id = e.id
AND d.fecha_programada = CURDATE()
AND a.estado NOT IN ('liberada','completada')
) AS domicilios_hoy
FROM lab_enfermeras e
$where
ORDER BY e.nombre_completo ASC
");
}
/**
* Enfermera por ID.
*/
public function obtener(int $id): ?array {
return $this->db->fetch(
'SELECT * FROM lab_enfermeras WHERE id = ?',
[$id]
) ?: null;
}
/**
* Crear enfermera.
*/
public function crear(array $datos, ?int $adminId = null): int {
$campos = $this->filtrarCampos($datos);
$id = $this->db->insert('lab_enfermeras', $campos);
$this->log->registrar($adminId, 'enfermeras', 'crear', $id, $campos);
return $id;
}
/**
* Actualizar enfermera.
*/
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
$campos = $this->filtrarCampos($datos);
$ok = $this->db->update('lab_enfermeras', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'enfermeras', 'editar', $id, $campos);
return $ok > 0;
}
/**
* Desactivar (soft-delete).
*/
public function desactivar(int $id, ?int $adminId = null): bool {
$ok = $this->db->update('lab_enfermeras', ['is_active' => 0], 'id = ?', [$id]);
$this->log->registrar($adminId, 'enfermeras', 'desactivar', $id);
return $ok > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// Agenda
// ─────────────────────────────────────────────────────────────────────────
/**
* Agenda completa de una enfermera (domicilios asignados con detalle).
*
* @param int $enfermeraId
* @param string $fecha YYYY-MM-DD, por defecto hoy
*/
public function agenda(int $enfermeraId, string $fecha = ''): array {
$fecha = $fecha ?: date('Y-m-d');
return $this->db->fetchAll("
SELECT
d.id AS domicilio_id,
d.fecha_programada,
d.hora_programada,
d.estado AS domicilio_estado,
d.direccion,
d.barrio,
d.tipo_servicio,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono,
a.estado AS asignacion_estado,
a.id AS asignacion_id
FROM lab_asignaciones a
JOIN lab_domicilios d ON d.id = a.domicilio_id
JOIN lab_pacientes p ON p.id = d.paciente_id
WHERE a.enfermera_id = ?
AND d.fecha_programada = ?
ORDER BY d.hora_programada ASC
", [$enfermeraId, $fecha]);
}
/**
* Carga de trabajo por período: domicilios asignados por día.
*/
public function cargaTrabajo(int $enfermeraId, string $desde, string $hasta): array {
return $this->db->fetchAll("
SELECT
d.fecha_programada,
COUNT(*) AS total,
SUM(d.estado = 'completado') AS completados
FROM lab_asignaciones a
JOIN lab_domicilios d ON d.id = a.domicilio_id
WHERE a.enfermera_id = ?
AND d.fecha_programada BETWEEN ? AND ?
GROUP BY d.fecha_programada
ORDER BY d.fecha_programada ASC
", [$enfermeraId, $desde, $hasta]);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function filtrarCampos(array $datos): array {
$permitidos = [
'numero_documento', 'tipo_documento', 'nombre_completo',
'telefono', 'telefono_alt', 'email',
'zona', 'notas', 'is_active',
];
return array_intersect_key($datos, array_flip($permitidos));
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
/**
* Formulario — Gestión de plantillas y envíos de formularios clínicos.
*/
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
class Formulario {
private Database $db;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// PLANTILLAS (admin)
// ─────────────────────────────────────────────────────────────────────────
public function listar(bool $soloActivos = true): array {
$w = $soloActivos ? 'WHERE f.is_active = 1' : '';
return $this->db->fetchAll("
SELECT f.*, u.full_name AS creado_por_nombre,
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) AS total_envios,
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) AS total_completados
FROM lab_formularios f
LEFT JOIN admin_users u ON u.id = f.creado_por
$w
ORDER BY f.created_at DESC
");
}
public function obtener(int $id): ?array {
$row = $this->db->fetch(
"SELECT f.*, u.full_name AS creado_por_nombre
FROM lab_formularios f
LEFT JOIN admin_users u ON u.id = f.creado_por
WHERE f.id = ?",
[$id]
);
if ($row) {
$row['esquema_decoded'] = json_decode($row['esquema'], true) ?? [];
}
return $row ?: null;
}
public function crear(array $datos, int $adminId): int {
$this->validarEsquema($datos['esquema'] ?? '[]');
$id = $this->db->insert('lab_formularios', [
'nombre' => trim($datos['nombre']),
'descripcion' => $datos['descripcion'] ?? null,
'categoria' => $datos['categoria'] ?? 'otro',
'esquema' => $datos['esquema'] ?? '[]',
'permite_firma' => !empty($datos['permite_firma']) ? 1 : 0,
'requiere_firma' => !empty($datos['requiere_firma']) ? 1 : 0,
'doc_encabezado' => $datos['doc_encabezado'] ?? null,
'doc_subtitulo' => $datos['doc_subtitulo'] ?? null,
'doc_logo_base64' => $datos['doc_logo_base64'] ?? null,
'doc_color' => $datos['doc_color'] ?? null,
'doc_pie_pagina' => $datos['doc_pie_pagina'] ?? null,
'creado_por' => $adminId,
]);
$this->log->registrar($adminId, 'formularios', 'crear', $id, ['nombre' => $datos['nombre']]);
return $id;
}
public function actualizar(int $id, array $datos, int $adminId): bool {
$permitidos = [
'nombre','descripcion','categoria','esquema',
'permite_firma','requiere_firma','is_active',
'doc_encabezado','doc_subtitulo','doc_logo_base64','doc_color','doc_pie_pagina',
];
$campos = array_intersect_key($datos, array_flip($permitidos));
if (isset($campos['esquema'])) {
$this->validarEsquema($campos['esquema']);
$campos['version'] = (int)$this->db->fetch('SELECT version FROM lab_formularios WHERE id = ?', [$id])['version'] + 1;
}
$ok = $this->db->update('lab_formularios', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'formularios', 'editar', $id, $campos);
return $ok > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// ENVÍOS
// ─────────────────────────────────────────────────────────────────────────
/**
* Crear un envío con datos pre-llenados, genera token único.
*/
public function crearEnvio(array $datos, int $enviadoPor): int {
$token = bin2hex(random_bytes(32));
$expira = date('Y-m-d H:i:s', strtotime('+7 days'));
$id = $this->db->insert('lab_form_envios', [
'formulario_id' => (int)$datos['formulario_id'],
'paciente_id' => !empty($datos['paciente_id']) ? (int)$datos['paciente_id'] : null,
'domicilio_id' => !empty($datos['domicilio_id']) ? (int)$datos['domicilio_id'] : null,
'token' => $token,
'datos_prefilled' => $datos['datos_prefilled'] ?? '{}',
'estado' => 'pendiente',
'enviado_por' => $enviadoPor,
'enviado_via' => $datos['enviado_via'] ?? 'whatsapp',
'expira_en' => $expira,
]);
$this->log->registrar($enviadoPor, 'formularios', 'enviar', $id, [
'formulario_id' => $datos['formulario_id'],
'via' => $datos['enviado_via'] ?? 'whatsapp',
]);
return $id;
}
/**
* Obtener envío por token (uso público del cliente).
*/
public function obtenerPorToken(string $token): ?array {
$envio = $this->db->fetch(
"SELECT e.*, f.nombre AS form_nombre, f.esquema, f.permite_firma, f.requiere_firma, f.descripcion AS form_descripcion,
f.doc_color, f.doc_logo_base64, f.doc_encabezado, f.doc_subtitulo, f.doc_pie_pagina,
p.nombre_completo AS paciente_nombre, p.numero_documento, p.telefono AS paciente_telefono
FROM lab_form_envios e
JOIN lab_formularios f ON f.id = e.formulario_id
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
WHERE e.token = ?",
[$token]
);
if (!$envio) return null;
if ($envio['estado'] === 'expirado') return null;
if ($envio['expira_en'] && strtotime($envio['expira_en']) < time()) {
$this->db->update('lab_form_envios', ['estado' => 'expirado'], 'id = ?', [$envio['id']]);
return null;
}
$envio['esquema_decoded'] = json_decode($envio['esquema'], true) ?? [];
$envio['prefilled_decoded'] = json_decode($envio['datos_prefilled'], true) ?? [];
return $envio;
}
/**
* Guardar respuesta del cliente (firma + datos).
*/
public function guardarRespuesta(string $token, array $datosCliente, ?string $firmaSvg, string $ip, string $ua): bool {
$envio = $this->db->fetch('SELECT id, estado FROM lab_form_envios WHERE token = ?', [$token]);
if (!$envio || $envio['estado'] === 'expirado') return false;
$tieneCanvas = !empty($firmaSvg);
$tieneFoto = !empty($datosCliente['__firma_foto']);
$estado = ($tieneCanvas || $tieneFoto) ? 'firmado' : 'completado';
// Hash de verificación SHA-256 (vincula contenido + firma + timestamp)
$payloadHash = json_encode($datosCliente, JSON_UNESCAPED_UNICODE)
. ($firmaSvg ?? '')
. $envio['id']
. $envio['token']
. date('Y-m-d H:i:s');
$hash = hash('sha256', $payloadHash);
$this->db->update('lab_form_envios', [
'datos_cliente' => json_encode($datosCliente, JSON_UNESCAPED_UNICODE),
'firma_svg' => $firmaSvg,
'hash_verificacion' => $hash,
'ip_cliente' => $ip,
'user_agent' => substr($ua, 0, 512),
'estado' => $estado,
'completado_en' => date('Y-m-d H:i:s'),
], 'token = ?', [$token]);
return true;
}
/**
* Listar envíos (admin/enfermero).
*/
public function listarEnvios(array $filtros = []): array {
$where = ['1=1'];
$params = [];
if (!empty($filtros['formulario_id'])) { $where[] = 'e.formulario_id = ?'; $params[] = $filtros['formulario_id']; }
if (!empty($filtros['paciente_id'])) { $where[] = 'e.paciente_id = ?'; $params[] = $filtros['paciente_id']; }
if (!empty($filtros['enviado_por'])) { $where[] = 'e.enviado_por = ?'; $params[] = $filtros['enviado_por']; }
if (!empty($filtros['estado'])) { $where[] = 'e.estado = ?'; $params[] = $filtros['estado']; }
$w = implode(' AND ', $where);
return $this->db->fetchAll("
SELECT e.*, f.nombre AS form_nombre, f.categoria, f.esquema,
p.nombre_completo AS paciente_nombre,
u.full_name AS enviado_por_nombre
FROM lab_form_envios e
JOIN lab_formularios f ON f.id = e.formulario_id
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
LEFT JOIN admin_users u ON u.id = e.enviado_por
WHERE $w
ORDER BY e.created_at DESC
LIMIT 200
", $params);
}
// ─────────────────────────────────────────────────────────────────────────
// Helper
// ─────────────────────────────────────────────────────────────────────────
private function validarEsquema(string $json): void {
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
throw new InvalidArgumentException('El esquema del formulario no es un JSON válido');
}
}
}
+316
View File
@@ -0,0 +1,316 @@
<?php
/**
* OrdenMedica — Gestión de órdenes médicas recibidas por WhatsApp.
*
* Flujo de estados:
* pendiente → en_revision → autorizada → en_domicilio → completada
* └→ rechazada
*/
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
class OrdenMedica {
// Estados válidos
const ESTADOS = [
'pendiente',
'en_revision',
'autorizada',
'rechazada',
'en_domicilio',
'completada',
];
private Database $db;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// CRUD
// ─────────────────────────────────────────────────────────────────────────
/**
* Lista paginada de órdenes con filtros.
*
* @param array $filtros estado, paciente_id, desde, hasta, busqueda
*/
public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array {
$wheres = [];
$params = [];
if (!empty($filtros['estado'])) {
$wheres[] = 'o.estado = ?';
$params[] = $filtros['estado'];
}
if (!empty($filtros['paciente_id'])) {
$wheres[] = 'o.paciente_id = ?';
$params[] = $filtros['paciente_id'];
}
if (!empty($filtros['desde'])) {
$wheres[] = 'DATE(o.created_at) >= ?';
$params[] = $filtros['desde'];
}
if (!empty($filtros['hasta'])) {
$wheres[] = 'DATE(o.created_at) <= ?';
$params[] = $filtros['hasta'];
}
if (!empty($filtros['busqueda'])) {
$like = '%' . $filtros['busqueda'] . '%';
$wheres[] = '(p.nombre_completo LIKE ? OR p.numero_documento LIKE ?)';
$params[] = $like;
$params[] = $like;
}
$where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : '';
$offset = ($pagina - 1) * $porPagina;
$total = $this->db->fetch(
"SELECT COUNT(*) AS n
FROM lab_ordenes_medicas o
JOIN lab_pacientes p ON p.id = o.paciente_id
$where",
$params
)['n'] ?? 0;
$rows = $this->db->fetchAll("
SELECT
o.*,
p.nombre_completo AS paciente_nombre,
p.numero_documento,
p.telefono AS paciente_telefono,
rev.full_name AS revisada_por_nombre,
aut.full_name AS autorizada_por_nombre
FROM lab_ordenes_medicas o
JOIN lab_pacientes p ON p.id = o.paciente_id
LEFT JOIN admin_users rev ON rev.id = o.revisada_por
LEFT JOIN admin_users aut ON aut.id = o.autorizada_por
$where
ORDER BY o.created_at DESC
LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset]));
return [
'data' => $rows,
'total' => (int)$total,
'pagina' => $pagina,
'por_pagina' => $porPagina,
'paginas' => (int)ceil($total / $porPagina),
];
}
/**
* Obtener una orden por ID (con todos los datos relacionados).
*/
public function obtener(int $id): ?array {
$orden = $this->db->fetch("
SELECT
o.*,
p.nombre_completo AS paciente_nombre,
p.numero_documento,
p.telefono AS paciente_telefono,
p.eps AS paciente_eps,
p.user_id,
u.phone_number,
rev.full_name AS revisada_por_nombre,
aut.full_name AS autorizada_por_nombre,
c.local_file AS conv_local_file,
c.whatsapp_media_id AS conv_media_id
FROM lab_ordenes_medicas o
JOIN lab_pacientes p ON p.id = o.paciente_id
LEFT JOIN users u ON u.id = p.user_id
LEFT JOIN admin_users rev ON rev.id = o.revisada_por
LEFT JOIN admin_users aut ON aut.id = o.autorizada_por
LEFT JOIN conversations c ON c.id = o.conversation_id
WHERE o.id = ?
", [$id]);
if (!$orden) {
return null;
}
// Historial de cambios de estado
$orden['historial'] = $this->db->fetchAll("
SELECT
la.*,
au.full_name AS admin_nombre
FROM lab_autorizaciones la
LEFT JOIN admin_users au ON au.id = la.realizada_por
WHERE la.orden_id = ?
ORDER BY la.created_at ASC
", [$id]);
return $orden;
}
/**
* Crear nueva orden médica.
*/
public function crear(array $datos, ?int $adminId = null): int {
$campos = $this->filtrarCampos($datos);
$id = $this->db->insert('lab_ordenes_medicas', $campos);
// Registrar en historial
$this->registrarCambioEstado($id, null, 'pendiente', $adminId, 'Orden creada');
$this->log->registrar($adminId, 'ordenes', 'crear', $id, $campos);
return $id;
}
/**
* Actualizar datos de la orden (sin cambiar estado).
*/
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
$campos = $this->filtrarCampos($datos);
unset($campos['estado']); // el estado se cambia con cambiarEstado()
$ok = $this->db->update('lab_ordenes_medicas', $campos, 'id = ?', [$id]);
if ($ok) {
$this->registrarCambioEstado($id, null, null, $adminId, 'Datos actualizados', 'editada');
$this->log->registrar($adminId, 'ordenes', 'editar', $id, $campos);
}
return $ok > 0;
}
// ─────────────────────────────────────────────────────────────────────────
// Gestión de estados (flujo de revisión)
// ─────────────────────────────────────────────────────────────────────────
/**
* Cambia el estado de la orden con trazabilidad completa.
*
* @param int $id
* @param string $nuevoEstado Debe estar en self::ESTADOS
* @param int $adminId
* @param string $comentario
*/
public function cambiarEstado(
int $id,
string $nuevoEstado,
int $adminId,
string $comentario = ''
): bool {
if (!in_array($nuevoEstado, self::ESTADOS)) {
throw new InvalidArgumentException("Estado inválido: $nuevoEstado");
}
$orden = $this->obtener($id);
if (!$orden) {
throw new RuntimeException("Orden #$id no encontrada");
}
$estadoAnterior = $orden['estado'];
$ahora = date('Y-m-d H:i:s');
$campos = ['estado' => $nuevoEstado];
// Campos adicionales según el nuevo estado
if ($nuevoEstado === 'en_revision') {
$campos['revisada_por'] = $adminId;
$campos['revisada_at'] = $ahora;
} elseif (in_array($nuevoEstado, ['autorizada', 'rechazada'])) {
$campos['autorizada_por'] = $adminId;
$campos['autorizada_at'] = $ahora;
$campos['comentario_revision'] = $comentario;
}
$ok = $this->db->update('lab_ordenes_medicas', $campos, 'id = ?', [$id]);
if ($ok) {
$this->registrarCambioEstado($id, $estadoAnterior, $nuevoEstado, $adminId, $comentario);
$this->log->registrar($adminId, 'ordenes', $nuevoEstado, $id, [
'estado_anterior' => $estadoAnterior,
'comentario' => $comentario,
]);
}
return $ok > 0;
}
// Alias de conveniencia
public function autorizar(int $id, int $adminId, string $comentario = ''): bool {
return $this->cambiarEstado($id, 'autorizada', $adminId, $comentario);
}
public function rechazar(int $id, int $adminId, string $motivo): bool {
return $this->cambiarEstado($id, 'rechazada', $adminId, $motivo);
}
public function iniciarRevision(int $id, int $adminId): bool {
return $this->cambiarEstado($id, 'en_revision', $adminId);
}
// ─────────────────────────────────────────────────────────────────────────
// Estadísticas / Dashboard
// ─────────────────────────────────────────────────────────────────────────
/**
* Conteo de órdenes por estado.
*/
public function contadorPorEstado(): array {
$rows = $this->db->fetchAll("
SELECT estado, COUNT(*) AS total
FROM lab_ordenes_medicas
GROUP BY estado
");
$result = array_fill_keys(self::ESTADOS, 0);
foreach ($rows as $row) {
$result[$row['estado']] = (int)$row['total'];
}
return $result;
}
/**
* Órdenes pendientes más antiguas (sin revisar).
*/
public function pendientesAntiguos(int $limit = 10): array {
return $this->db->fetchAll("
SELECT
o.*,
p.nombre_completo,
TIMESTAMPDIFF(HOUR, o.created_at, NOW()) AS horas_espera
FROM lab_ordenes_medicas o
JOIN lab_pacientes p ON p.id = o.paciente_id
WHERE o.estado = 'pendiente'
ORDER BY o.created_at ASC
LIMIT ?
", [$limit]);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers privados
// ─────────────────────────────────────────────────────────────────────────
private function registrarCambioEstado(
int $ordenId,
?string $estadoAnterior,
?string $estadoNuevo,
?int $adminId,
string $comentario = '',
string $accion = ''
): void {
$accion = $accion ?: ($estadoNuevo ?? 'editada');
$this->db->insert('lab_autorizaciones', [
'orden_id' => $ordenId,
'accion' => $accion,
'estado_anterior'=> $estadoAnterior,
'estado_nuevo' => $estadoNuevo,
'realizada_por' => $adminId,
'comentario' => $comentario,
]);
}
private function filtrarCampos(array $datos): array {
$permitidos = [
'paciente_id', 'conversation_id', 'whatsapp_media_id', 'local_file',
'estado', 'medico_nombre', 'medico_registro', 'fecha_orden',
'diagnostico', 'examenes_solicitados', 'requiere_ayuno',
'horas_ayuno', 'indicaciones', 'revisada_por', 'revisada_at',
'autorizada_por', 'autorizada_at', 'comentario_revision', 'notas_admin',
];
return array_intersect_key($datos, array_flip($permitidos));
}
}
+215
View File
@@ -0,0 +1,215 @@
<?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';
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
): array {
$offset = ($pagina - 1) * $porPagina;
$like = "%$busqueda%";
$params = $busqueda
? [$like, $like, $like, $like]
: [];
$where = $busqueda
? "WHERE p.nombre_completo LIKE ?
OR p.numero_documento LIKE ?
OR p.telefono LIKE ?
OR p.email LIKE ?"
: '';
$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.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),
];
}
/**
* 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);
$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);
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
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]
);
return $this->crear([
'user_id' => $userId,
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
'telefono' => $user['phone_number'] ?? null,
]);
}
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',
];
return array_intersect_key($datos, array_flip($permitidos));
}
}