diff --git a/PLAN_ERP_MULTIMODULO.txt b/PLAN_ERP_MULTIMODULO.txt index 1f7ca46..e0c2f42 100644 --- a/PLAN_ERP_MULTIMODULO.txt +++ b/PLAN_ERP_MULTIMODULO.txt @@ -101,14 +101,21 @@ MÓDULOS PLANIFICADOS (oleadas): │ ├── turnero/ ← NUEVO ★ (Oleada 1) │ │ ├── module.php │ │ ├── views/ -│ │ │ ├── dashboard.php ← Pantalla administrador -│ │ │ ├── display.php ← Pantalla TV/recepción (sin login) -│ │ │ └── kiosko.php ← Pantalla táctil toma de turno +│ │ │ ├── dashboard.php ← Panel administrador (resumen del día) +│ │ │ ├── kiosko.php ← Pantalla táctil tomar turno (sin login) +│ │ │ ├── display.php ← Pantalla TV cola general (sin login) +│ │ │ ├── recepcion.php ← Puesto recepcionista (llamar, solicitud, lugar) +│ │ │ ├── lugar.php ← Puesto de servicio: llama turno, firma, atiende +│ │ │ └── configuracion.php ← Lugares, exámenes, prioridades, sesiones │ │ └── api/ -│ │ ├── create_turno.php -│ │ ├── llamar_turno.php -│ │ ├── get_estado.php -│ │ └── sse_turno.php ← Server-Sent Events para la pantalla TV +│ │ ├── create_turno.php ← Genera nuevo turno (kiosko) +│ │ ├── llamar_turno.php ← Llama siguiente de una cola +│ │ ├── create_solicitud.php ← Crea solicitud con exámenes, pago y lugar destino +│ │ ├── cambiar_estado.php ← Avanza estado del turno +│ │ ├── get_cola.php ← Cola de un lugar o recepción +│ │ ├── sse_turno.php ← Server-Sent Events para pantallas TV +│ │ ├── send_consentimiento.php ← Envía enlace de consentimiento por WhatsApp +│ │ └── get_consentimientos.php ← Estado de firmas del turno │ │ │ ├── registro_exams/ ← PENDIENTE — Oleada 2 │ │ ├── module.php @@ -215,54 +222,285 @@ HELPER PROPUESTO (core/Rbac.php): 5. MÓDULO TURNERO — DISEÑO DETALLADO ──────────────────────────────────────────────────────────────────────────────── -CONCEPTO: - Sistema de turnos para pacientes que llegan presencialmente a la sede. - Tres pantallas distintas: - A) Kiosko (tablet/touch en recepción) → el paciente toma su turno - B) TV/Display (pantalla grande) → muestra turno actual + cola - C) Dashboard admin → el operador llama turnos, gestiona +PROPÓSITO: + Diseñar, desarrollar e implementar un Sistema de Turnero Inteligente + integrado con gestión de pacientes, clasificación por prioridades, + flujo de atención por áreas (Recepción y Toma de Muestras) y envío + automatizado de consentimientos informados mediante WhatsApp. -TABLAS BD NUEVAS: + El sistema optimiza la atención, mejora la trazabilidad del paciente + y garantiza el cumplimiento legal mediante la gestión digital de + consentimientos. - turnero_servicios - id, nombre, prefijo (ej. "A"), color, tiempo_estimado_min, activo +──── 5.1 PANTALLAS / INTERFACES ──────────────────────────────────────────── + + A) Kiosko (tablet/touch, sin login) + → el paciente selecciona su tipo de prioridad y obtiene su turno + + B) Pantalla TV / Display (pantalla grande, sin login, una por lugar) + → muestra la cola del lugar y el turno siendo llamado en tiempo real + → param ?lugar_id=X para que cada sala tenga su propia pantalla + + C) Puesto Recepción (login requerido) + → llama turnos de la cola general + → vincula paciente, registra exámenes, recibe pago + → crea la Solicitud Interna y asigna el turno a un Lugar + + D) Puesto Lugar / Estación de Servicio (login requerido, genérico) + → llama turnos de su propia cola (los asignados a él por recepción) + → muestra exámenes requeridos y estado de consentimientos + • el paciente firma directamente en la pantalla del puesto (o en su celular) + → realiza el servicio y finaliza el turno + → La misma vista sirve para: Toma de Muestras 1, Toma de Muestras 2, + Rayos X, Ultrasonido, o cualquier área que se configure + + E) Panel Admin Turnero (login requerido) + → configura Lugares, exámenes, prioridades, sesiones + +──── 5.2 SISTEMA DE PRIORIDADES ──────────────────────────────────────────── + + Código Tipo de paciente Orden motor de cola + ────────────────────────────────────────────────────── + A Niños 1 (máxima prioridad) + B Embarazadas 2 + C Adulto mayor 3 + D Discapacidad 4 + E Paciente general 5 + F Muestras pendientes 6 (menor prioridad) + + Regla: dentro del mismo código se respeta el orden de llegada (FIFO). + El motor de cola mezcla todas las filas usando el peso de prioridad + timestamp. + +──── 5.3 FLUJO POR ÁREAS ──────────────────────────────────────────────────── + + ÁREA 1 — RECEPCIÓN + ═══════════════════ + Estados: espera → en_recepcion → finalizado_recepcion + + 1. Kiosko/recepcionista genera turno (código + prioridad) + 2. Pantalla TV muestra la cola + 3. Recepcionista llama siguiente turno → estado "en_recepcion" + → (opcional) Mensaje WhatsApp al paciente con su código de turno + 4. Se vincula el turno con un número de ventanilla + 5. Se registran: + • Datos personales del paciente (buscar/crear en lab_pacientes) + • Exámenes requeridos (lista de exam_tipos) + 6. Sistema consulta si los exámenes requieren consentimientos + 7. Si hay consentimientos pendientes: + → Se genera enlace único y seguro por paciente + → Se envía por WhatsApp usando WhatsAppService + plantilla aprobada Meta + Si no se requieren → pasa directamente a área de muestras + 8. Turno avanza → "esperando_consentimiento" o "en_espera_muestra" + + ÁREA 2 — TOMA DE MUESTRAS + ══════════════════════════ + Estados: en_espera_muestra → en_muestra → finalizado + + 1. Bacteriólogo/auxiliar llama siguiente de la cola de muestras + 2. Sistema BLOQUEA la atención si no existen consentimientos firmados + → muestra alerta visual; no permite avanzar el estado + 3. Si todos los consentimientos están firmados: + → Estado "en_muestra" + → Se validan los exámenes registrados en recepción + 4. Al terminar la toma: estado "finalizado" + +──── 5.4 CONSENTIMIENTOS INFORMADOS ───────────────────────────────────────── + + INTEGRACIÓN CON MÓDULO FORMULARIOS (REUTILIZAR lo que ya existe): + • Los consentimientos son Formularios del módulo lab_formularios/ + • Se marca en la configuración del formulario si es de tipo "consentimiento" + usando un campo nuevo: tipo ENUM(formulario, consentimiento) + • Enlace público ya existe en el módulo → se reutiliza tal cual + • Firma digital ya implementada → se reutiliza (canvas + checkbox legal) + + CATÁLOGO DE TIPOS DE EXAMEN (exam_tipos): + • Listado maestro de exámenes que ofrece el laboratorio (código + nombre) + • Se crea en FASE 3 (Oleada 1) para dar soporte al turnero + • La misma tabla se usa en Oleada 2 (registro_exams) sin cambios + • Administrado desde la vista Configuración > Exámenes y Consentimientos + + RELACIÓN EXAMEN ↔ CONSENTIMIENTO (exam_tipo_consentimientos): + • Un consentimiento (formulario) puede cubrir VARIOS tipos de examen + • Un examen puede estar en un solo consentimiento o en ninguno + • Se configura UNA VEZ en el panel de administración, no en cada turno + • Ejemplo: + "Toma de muestra de sangre" → consentimiento id=3 ("Consentimiento hemograma") + "Glucosa en ayunas" → consentimiento id=3 + "Cultivo de orina" → consentimiento id=5 ("Consentimiento urocultivo") + "Presión arterial" → (sin consentimiento) + + FLUJO DE CONSENTIMIENTOS: + 1. Recepción selecciona exámenes del turno (de la lista exam_tipos) + 2. Sistema busca en exam_tipo_consentimientos qué formularios aplican + para los exámenes seleccionados → deduplica si varios exámenes + comparten el mismo consentimiento → lista mínima de consentimientos + 3. Por cada consentimiento pendiente: + • Se genera un token único (UUID) → link = /ver_formulario.php?token=XXX + • Se registra en turnero_consentimientos con estado "pendiente" + 4. (Opcional) Recepción envía enlace por WhatsApp para pre-firma + mientras el paciente espera en cola del Lugar + 5. En el Lugar (segunda llamada), el paciente puede firmar: + OPCIÓN A: Hace clic en el enlace de WhatsApp en su celular + OPCIÓN B: El auxiliar muestra la pantalla del puesto al paciente + y firma directamente ahí (mismo ver_formulario_enviado.php) + 6. Al firmar → estado "firmado" en turnero_consentimientos + 7. Vista del Lugar muestra ✓ verde / ✗ rojo en tiempo real (polling 5s) + BLOQUEO total si alguno pendiente → no se puede iniciar el servicio + + MOMENTO DE LA FIRMA: + • Preferiblemente antes de la segunda llamada (via WhatsApp, mientras espera) + • Alternativamente EN la estación del Lugar (tablet del puesto) + • NUNCA después de iniciado el servicio + + EVIDENCIA REGISTRADA POR FIRMA: + • Fecha y hora exacta + • IP del firmante + • User-Agent (dispositivo) + • Versión del formulario/consentimiento al momento de firmar + +──── 5.5 TABLAS BD NUEVAS ──────────────────────────────────────────────────── + + exam_tipos ← catálogo maestro de exámenes del laboratorio + id, codigo VARCHAR(20), ← ej. "HEM", "GLU", "URIN" + nombre VARCHAR(150), ← ej. "Hemograma completo" + categoria VARCHAR(80), ← ej. "Hematología", "Química" + activo TINYINT(1) + NOTA: Esta tabla es compartida con Oleada 2 (registro_exams). + En Oleada 2 se le agregan columnas precio_base, requiere_ayunas, etc. + + exam_tipo_consentimientos ← relación M:N examen ↔ consentimiento + id, + exam_tipo_id INT FK exam_tipos, + formulario_id INT FK lab_formularios, ← debe tener tipo='consentimiento' + UNIQUE KEY (exam_tipo_id, formulario_id) + NOTA: Si un examen no tiene registro aquí, no se exige consentimiento. + Si varios exámenes del mismo turno apuntan al mismo formulario_id, + se genera UN SOLO consentimiento (deduplicado en la lógica PHP). + + turnero_lugares ← sub-módulo: estaciones/lugares de servicio + id, nombre VARCHAR(100), ← ej. "Toma de Muestras 1", "Rayos X" + descripcion TEXT, + activo TINYINT(1), + sort_order INT ← orden en la pantalla TV general + NOTA: Recepción NO es un lugar; es el primer paso implícito del flujo. + Cada lugar tiene su propia cola y su propia pantalla TV. + + turnero_prioridades ← catálogo editable de códigos A-F + id, codigo CHAR(1) UNIQUE, nombre VARCHAR(100), + orden_peso INT, ← número menor = mayor prioridad + color VARCHAR(7), + activo TINYINT(1) turnero_sesiones - id, fecha, abierto_por INT FK admin_users, cerrado_por, inicio_at, fin_at - (una sesión = un día de atención) + id, fecha DATE, + abierto_por INT FK admin_users, cerrado_por INT FK admin_users, + inicio_at DATETIME, fin_at DATETIME turnero_turnos - id, sesion_id, - numero INT, ← número correlativo de la sesión - codigo VARCHAR(10), ← "A001", "B012" - servicio_id, - paciente_nombre VARCHAR(150), + id, sesion_id FK turnero_sesiones, + numero INT, ← correlativo dentro de la sesión + codigo VARCHAR(10), ← "A001", "B012" + prioridad_id INT FK turnero_prioridades, + paciente_nombre VARCHAR(150), ← capturado en kiosko (opcional) paciente_cel VARCHAR(20), - estado ENUM(espera, llamado, en_atencion, atendido, ausente, cancelado), - modulo_atencion INT, ← puesto/ventanilla que atiende - llamado_at, inicio_at, fin_at, creado_at + estado ENUM( + espera, ← código asignado, en cola general + en_recepcion, ← recepcionista lo está procesando + en_espera_lugar, ← solicitud creada, en cola del Lugar + en_servicio, ← siendo atendido en el Lugar + finalizado, ← proceso completo + ausente, ← no se presentó + cancelado + ), + lugar_destino_id INT FK turnero_lugares NULL, ← asignado por recepción + creado_at DATETIME, + llamado_recepcion_at DATETIME, + inicio_recepcion_at DATETIME, + fin_recepcion_at DATETIME, + llamado_lugar_at DATETIME, + inicio_lugar_at DATETIME, + fin_lugar_at DATETIME, + atendido_recepcion_por INT FK admin_users NULL, + atendido_lugar_por INT FK admin_users NULL - turnero_modulos - id, nombre (ej. "Ventanilla 1"), activo, usuario_actual INT FK admin_users + turnero_solicitudes ← solicitud interna de agendamiento (crea recepción) + id, + turno_id INT FK turnero_turnos UNIQUE, ← 1 solicitud por turno + paciente_id INT FK lab_pacientes, ← vinculado en recepción + lugar_id INT FK turnero_lugares, ← lugar de destino + total_cobrado DECIMAL(10,2), + metodo_pago ENUM(efectivo, transferencia, tarjeta, eps, cortesia), + observaciones TEXT, + creado_por INT FK admin_users, + creado_at DATETIME -FLUJO: - 1. Recepcionista / kiosko crea turno → estado "espera" - 2. Operador en dashboard hace clic "Llamar siguiente" - → estado "llamado", pantalla TV muestra "A-001 → Ventanilla 2" - → (opcional) SMS/WhatsApp al paciente con su turno - 3. El paciente llega → operador pasa a "en_atención" - 4. Al terminar → "atendido" - 5. Si no aparece → "ausente" (puede regresar al final de la cola) + turnero_examen_items ← exámenes de la solicitud + id, solicitud_id FK turnero_solicitudes, + exam_tipo_id INT FK exam_tipos, + creado_at DATETIME -PANTALLA TV (display.php): - • Sin login - • Se actualiza por SSE (Server-Sent Events) cada vez que se llama un turno - • Muestra: turno actual por cada ventanilla + próximos 5 en espera - • Diseño visual grande, colores por servicio + turnero_consentimientos ← consentimientos del turno (deduplicados) + id, turno_id FK turnero_turnos, + formulario_id INT FK lab_formularios, + token VARCHAR(64) UNIQUE, ← enlace único de firma + estado ENUM(pendiente, enviado, visto, firmado, rechazado), + enviado_at DATETIME, + firmado_at DATETIME, + ip_firma VARCHAR(45), + ua_firma VARCHAR(500), + version_formulario INT ← snapshot de la versión al momento de firma -INTEGRACIÓN CON WHATSAPP (OPCIONAL): - Cuando se llama el turno → enviar mensaje al paciente si dejó celular - Usar WhatsAppService ya existente. + formularios.tipo ← columna nueva en tabla existente lab_formularios + ALTER TABLE lab_formularios ADD COLUMN tipo + ENUM('formulario','consentimiento') DEFAULT 'formulario'; + + DIAGRAMA DE RELACIONES CLAVE: + + lab_formularios (tipo='consentimiento') + │ 1 + │ + exam_tipo_consentimientos ─────── exam_tipos + (M:N) │ 1 + │ N + turnero_examen_items + │ N + │ 1 + turnero_solicitudes ───── turnero_lugares + │ 1 + │ 1 + turnero_turnos + │ 1 + │ N + turnero_consentimientos + (un registro por formulario distinto requerido por el turno) + +──── 5.6 INTEGRACIONES CON MÓDULOS EXISTENTES ────────────────────────────── + + • lab_pacientes → buscar/crear paciente al crear la solicitud en recepción + • lab_formularios → los consentimientos SON formularios del sistema + • ver_formulario_enviado.php → página pública de firma (ya funcional) + se abre también desde la pantalla del Lugar + • WhatsAppService → envío del enlace de consentimiento (opcional, entre llamadas) + • Plantillas Meta → crear plantilla aprobada "consentimiento_turno" + • exam_tipos → catálogo compartido con Oleada 2 (registro_exams) + +──── 5.7 PANTALLA TV / DISPLAY ───────────────────────────────────────────── + + Hay VARIOS tipos de pantalla TV, todas sin login: + + TV RECEPCIÓN (?display=recepcion) + • Cola general (turnos en estado 'espera' ordenados por prioridad) + • Turno actualmente en recepción (código grande + "DIRÍJASE A RECEPCIÓN") + + TV POR LUGAR (?display=lugar&lugar_id=X) + • Cola del Lugar X (turnos en 'en_espera_lugar' con ese lugar_destino_id) + • Turno en servicio activo del Lugar X (código grande) + • Cadaestación tiene su propia TV configurada con su lugar_id + + COMPORTAMIENTO COMÚN: + • Actualización por SSE en tiempo real sin reload + • Colores grandes por código de prioridad + • Animación + sonido corto al llamar un turno ──────────────────────────────────────────────────────────────────────────────── @@ -350,10 +588,16 @@ Migración 002 – Nuevos roles ('Operador Bot', 'operador_bot', 'Gestión de conversaciones', '#6f42c1', 0); Migración 003 – Turnero [Oleada 1] - CREATE TABLE turnero_servicios (...) - CREATE TABLE turnero_sesiones (...) - CREATE TABLE turnero_turnos (...) - CREATE TABLE turnero_modulos (...) + CREATE TABLE exam_tipos (...) ← catálogo maestro compartido con Oleada 2 + CREATE TABLE exam_tipo_consentimientos(...) ← M:N: examen ↔ formulario consentimiento + CREATE TABLE turnero_lugares (...) ← sub-módulo: estaciones configurables + CREATE TABLE turnero_prioridades (...) ← catálogo A-F configurable + CREATE TABLE turnero_sesiones (...) ← sesión diaria de atención + CREATE TABLE turnero_turnos (...) ← turno con lugar_destino_id + CREATE TABLE turnero_solicitudes (...) ← solicitud interna de agendamiento + CREATE TABLE turnero_examen_items (...) ← exámenes de la solicitud + CREATE TABLE turnero_consentimientos (...) ← firma digital por consentimiento + ALTER TABLE lab_formularios ADD COLUMN tipo ENUM('formulario','consentimiento') DEFAULT 'formulario' Migración 004 – SYSTEM_MODULES dinámica (mover de config.php a BD) [Oleada 1] CREATE TABLE system_modules ( @@ -372,7 +616,10 @@ Migración 004 – SYSTEM_MODULES dinámica (mover de config.php a BD) [Oleada config.php mantiene el array como fallback hasta que la BD esté lista. Migración 005 – Registro de exámenes [Oleada 2] - CREATE TABLE exam_tipos (...) + -- exam_tipos ya existe desde Migración 003; solo agregar columnas: + ALTER TABLE exam_tipos ADD COLUMN precio_base DECIMAL(10,2) NULL; + ALTER TABLE exam_tipos ADD COLUMN requiere_ayunas TINYINT(1) DEFAULT 0; + ALTER TABLE exam_tipos ADD COLUMN instrucciones TEXT NULL; CREATE TABLE exam_ordenes (...) CREATE TABLE exam_items (...) CREATE TABLE exam_etiquetas(...) @@ -431,7 +678,7 @@ GRUPOS DEL MENÚ LATERAL: 🧪 Clínico (Oleada 2) Registro Exámenes | Resultados 🎟️ Turnero (Oleada 1) - Dashboard | Configurar Servicios + Dashboard | Recepción | Toma de Muestras | Kiosko | Configuración 📊 Reportes Ingresos | Rendimiento | Exportar ⚙️ Sistema @@ -467,21 +714,242 @@ FASE 2 – SYSTEM_MODULES dinámica (2 días) ✅ COMPLETADA ── MÓDULOS NUEVOS ──────────────────────────────────────────────────────────── -FASE 3 – Turnero MVP — Oleada 1 (3-5 días) - □ Migración 003: tablas turnero_* - □ modules/turnero/module.php + views/ + api/ - □ Pantalla kiosko (toma turno, sin login) - □ Pantalla display TV (SSE, sin login) - □ Dashboard admin (llamar, gestionar) - □ Registrar slug turnero en system_modules +FASE 3 – Turnero MVP — Oleada 1 + + FASE 3.1 – Base de datos y configuración (1 día) ✅ COMPLETADA + ────────────────────────────────────────────────── + ✓ Migración 003: crear tablas + CREATE exam_tipos ← catálogo maestro (compartido con Oleada 2) + CREATE exam_tipo_consentimientos ← M:N: examen↔formulario + CREATE turnero_lugares ← estaciones de servicio configurables + CREATE turnero_prioridades (insertar datos A-F por defecto) + CREATE turnero_sesiones + CREATE turnero_turnos ← con lugar_destino_id + CREATE turnero_solicitudes ← solicitud interna de agendamiento + CREATE turnero_examen_items ← FK solicitud_id + CREATE turnero_consentimientos + ALTER lab_formularios ADD tipo ENUM(formulario, consentimiento) + ✓ modules/turnero/module.php (descriptor) + ✓ Registrar slug 'turnero' en system_modules + ✓ Asignar módulo turnero a roles recepcionista y bacteriologo + + ✅ FASE 3.2 – Motor de cola y API core (1 día) [COMPLETADA] + ────────────────────────────────────────────── + ✓ api/create_turno.php + • Crea/abre sesión del día si no existe + • Genera número correlativo + código (ej. "A001") con FOR UPDATE + • Admite paciente_nombre + paciente_cel (opcional) + • Retorna código, número y posición en cola + ✓ api/llamar_turno.php + • Recibe area (recepcion|lugar) + lugar_id + • Motor de prioridades: SELECT menor orden_peso → menor creado_at + • Área lugar: solo turnos en estado en_espera_lugar para ese lugar + • Retorna turno llamado o null si cola vacía + ✓ api/cambiar_estado.php + • Avanza estado con mapa de transiciones explícito + • Bloquea en_espera_lugar→en_servicio si hay consentimientos pendientes (422) + • Registra timestamps llamado_at / inicio_at / fin_at por área + ✓ api/get_cola.php + • Cola actual por área (recepcion | lugar + lugar_id) + • Turno activo + listado + estadísticas de sesión + ✓ api/sse_turno.php + • SSE: emite 'cola_update' cuando cambia sse_ping_at en la sesión + • ALTER TABLE IF NOT EXISTS para sse_ping_at (idempotente) + • Keep-alive ": ping" cada 15 s, forzar refresh cada 30 s + • Compatible con pantalla TV sin necesidad de polling activo + + ✅ FASE 3.3 – Pantallas sin login (1 día) [COMPLETADA] + ──────────────────────────────────────── + ✓ views/kiosko.php + • Pantalla táctil fullscreen 3 pasos: prioridad → datos → ticket + • Botones A-F cargados de BD (icono + nombre + descripción + color) + • Campo opcional: nombre y celular con validación de formato + • Muestra código + posición en cola; auto-reinicio tras 30 s + • Sin login; fetch POST a create_turno.php + ✓ views/display.php + • Pantalla TV fullscreen, sin login (HTML puro, sin false, 'error' => 'Error interno: ' . $err['message']]); + } +}); + +header('Content-Type: application/json; charset=utf-8'); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ── Respuestas JSON ────────────────────────────────────────── + +function jsonOk(array $payload = [], string $mensaje = ''): void +{ + ob_clean(); + $resp = ['ok' => true, 'success' => true]; + if ($mensaje) { + $resp['message'] = $mensaje; + } + echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE); + exit; +} + +function jsonError(string $mensaje, int $code = 400): void +{ + ob_clean(); + http_response_code($code); + echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE); + exit; +} + +function requireMethod(string $method): void +{ + if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) { + jsonError('Método no permitido', 405); + } +} + +function inputJson(): array +{ + static $parsed = null; + if ($parsed === null) { + $raw = file_get_contents('php://input'); + $parsed = json_decode($raw, true) ?? []; + } + return $parsed; +} + +// ── Autenticación ──────────────────────────────────────────── + +function requireAuth(): void +{ + if (empty($_SESSION['admin_user']['id'])) { + jsonError('No autenticado', 401); + } +} + +/** + * Exige sesión + acceso al módulo registro_exams. + */ +function requireExams(): void +{ + requireAuth(); + $modules = $_SESSION['admin_user']['modules'] ?? []; + $roleId = $_SESSION['admin_user']['role_id'] ?? null; + + // Superadmin / admin global = acceso total + if (empty($roleId)) { + return; + } + + if (!in_array('registro_exams', (array) $modules, true)) { + jsonError('Sin acceso al módulo Registro Exámenes', 403); + } +} + +function adminId(): ?int +{ + return isset($_SESSION['admin_user']['id']) + ? (int) $_SESSION['admin_user']['id'] + : null; +} + +// ── Helper BD ──────────────────────────────────────────────── + +function db(): PDO +{ + return Database::getInstance()->getConnection(); +} + +// ── Código de orden ────────────────────────────────────────── + +/** + * Genera el siguiente código de orden legible: EX-YYYYMMDD-NNNN + * Usa SELECT FOR UPDATE para evitar duplicados en concurrencia. + */ +function generarCodigoOrden(): string +{ + $pdo = db(); + $hoy = date('Ymd'); + $prefijo = "EX-{$hoy}-"; + + $stmt = $pdo->prepare( + "SELECT COALESCE(MAX(CAST(SUBSTRING(codigo, -4) AS UNSIGNED)), 0) + 1 AS siguiente + FROM exam_ordenes + WHERE codigo LIKE ? + FOR UPDATE" + ); + $stmt->execute(["{$prefijo}%"]); + $n = (int) $stmt->fetchColumn(); + + return $prefijo . str_pad($n, 4, '0', STR_PAD_LEFT); +} + +// ── Etiqueta de prioridad ──────────────────────────────────── + +function badgePrioridad(string $p): string +{ + return match ($p) { + 'urgente' => 'Urgente', + 'stat' => 'STAT', + default => 'Normal', + }; +} + +function badgeEstadoOrden(string $e): string +{ + return match ($e) { + 'en_proceso' => 'En proceso', + 'completa' => 'Completa', + 'entregada' => 'Entregada', + 'cancelada' => 'Cancelada', + default => 'Pendiente', + }; +} diff --git a/modules/registro_exams/api/cambiar_estado_item.php b/modules/registro_exams/api/cambiar_estado_item.php new file mode 100644 index 0000000..58f0363 --- /dev/null +++ b/modules/registro_exams/api/cambiar_estado_item.php @@ -0,0 +1,143 @@ +prepare('SELECT orden_id FROM exam_items WHERE id = ?'); + $stmtG->execute([$id]); + $item = $stmtG->fetch(PDO::FETCH_ASSOC); + if (!$item) { + jsonError('Ítem no encontrado', 404); + } + + $pdo->prepare( + 'UPDATE exam_items SET estado = ?, notas_tecnico = ?, creado_at = creado_at WHERE id = ?' + )->execute([$estado, $notas, $id]); + + // Si todos los ítems de la orden están listos → marcar orden como completa + if ($estado === 'resultado_listo' || $estado === 'entregado') { + $stmtC = $pdo->prepare( + "SELECT COUNT(*) FROM exam_items + WHERE orden_id = ? + AND estado NOT IN ('resultado_listo','entregado')" + ); + $stmtC->execute([$item['orden_id']]); + if ((int) $stmtC->fetchColumn() === 0) { + $pdo->prepare( + "UPDATE exam_ordenes SET estado = 'completa', updated_at = NOW() WHERE id = ?" + )->execute([$item['orden_id']]); + } + } + + jsonOk([], 'Estado actualizado'); + } + + // ── Crear o actualizar una muestra ────────────────────────── + case 'muestra': { + $id = isset($body['id']) && $body['id'] !== null ? (int) $body['id'] : null; + $estado = $body['estado'] ?? 'tomada'; + $ordenId = isset($body['orden_id']) ? (int) $body['orden_id'] : 0; + $tipoMuest = $body['tipo_muestra'] ?? 'sangre_venosa'; + $barcode = trim($body['codigo_barras'] ?? ''); + + if (!in_array($estado, $estadosMuestra, true)) { + jsonError('Estado de muestra inválido'); + } + + if ($id === null) { + // Crear nueva muestra + if ($ordenId <= 0) { + jsonError('orden_id requerido para crear muestra'); + } + $pdo->prepare(' + INSERT INTO exam_muestras + (orden_id, tipo_muestra, codigo_barras, tomada_por, tomada_at, estado) + VALUES (?, ?, ?, ?, NOW(), ?) + ')->execute([$ordenId, $tipoMuest, $barcode ?: null, adminId(), $estado]); + + $newId = (int) $pdo->lastInsertId(); + + // Marcar ítems como muestra_tomada si están pendientes + $pdo->prepare( + "UPDATE exam_items SET estado = 'muestra_tomada' + WHERE orden_id = ? AND estado = 'pendiente'" + )->execute([$ordenId]); + + // Actualizar orden a en_proceso si aún está pendiente + $pdo->prepare( + "UPDATE exam_ordenes SET estado = 'en_proceso', updated_at = NOW() + WHERE id = ? AND estado = 'pendiente'" + )->execute([$ordenId]); + + jsonOk(['muestra_id' => $newId], 'Muestra registrada'); + } else { + // Actualizar muestra existente + $pdo->prepare( + 'UPDATE exam_muestras SET estado = ?, codigo_barras = ? WHERE id = ?' + )->execute([$estado, $barcode ?: null, $id]); + + jsonOk([], 'Muestra actualizada'); + } + } + + // ── Cambiar estado de la orden completa ───────────────────── + case 'orden': { + $id = isset($body['id']) ? (int) $body['id'] : 0; + $estado = $body['estado'] ?? ''; + if ($id <= 0 || !in_array($estado, $estadosOrden, true)) { + jsonError('Parámetros inválidos para orden'); + } + $pdo->prepare( + 'UPDATE exam_ordenes SET estado = ?, updated_at = NOW() WHERE id = ?' + )->execute([$estado, $id]); + + jsonOk([], 'Estado de orden actualizado'); + } + + default: + jsonError('Tipo de operación desconocido: ' . $tipo); +} diff --git a/modules/registro_exams/api/get_orden.php b/modules/registro_exams/api/get_orden.php new file mode 100644 index 0000000..f6c8ece --- /dev/null +++ b/modules/registro_exams/api/get_orden.php @@ -0,0 +1,111 @@ + + * Devuelve la orden completa: datos, paciente, ítems, muestras, resultados. + */ + +require_once __DIR__ . '/_helpers.php'; +requireMethod('GET'); +requireExams(); + +$id = isset($_GET['id']) ? (int) $_GET['id'] : 0; +if ($id <= 0) { + jsonError('ID de orden inválido'); +} + +$pdo = db(); + +// Orden + paciente +$stmt = $pdo->prepare(' + SELECT + o.id, o.codigo, o.prioridad, o.estado, + o.requiere_ayuno, o.horas_ayuno, + o.medico_nombre, o.medico_registro, + o.diagnostico, o.notas, + o.solicitud_id, o.orden_medica_id, + o.creado_por, o.creado_at, o.updated_at, + p.id AS paciente_id, + p.nombre_completo, + p.numero_documento, + p.tipo_documento, + p.telefono, + p.email, + p.fecha_nacimiento, + p.genero, + p.eps + FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + WHERE o.id = ? +'); +$stmt->execute([$id]); +$orden = $stmt->fetch(PDO::FETCH_ASSOC); + +if (!$orden) { + jsonError('Orden no encontrada', 404); +} + +// Calcular edad +if ($orden['fecha_nacimiento']) { + $diff = (new DateTime($orden['fecha_nacimiento']))->diff(new DateTime()); + $orden['edad'] = $diff->y; +} else { + $orden['edad'] = null; +} + +// Ítems +$stmt = $pdo->prepare(' + SELECT + ei.id, ei.orden_id, ei.exam_tipo_id, ei.estado, ei.notas_tecnico, ei.creado_at, + et.codigo AS tipo_codigo, et.nombre AS tipo_nombre, + et.categoria, et.requiere_ayuno AS tipo_requiere_ayuno, + et.instrucciones AS tipo_instrucciones + FROM exam_items ei + JOIN exam_tipos et ON et.id = ei.exam_tipo_id + WHERE ei.orden_id = ? + ORDER BY et.categoria, et.nombre +'); +$stmt->execute([$id]); +$items = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// Resultados por ítem +$resultadosPorItem = []; +if ($items) { + $itemIds = array_column($items, 'id'); + $inPh = implode(',', array_fill(0, count($itemIds), '?')); + $stmt = $pdo->prepare(" + SELECT er.*, au.nombre AS ingresado_nombre + FROM exam_resultados er + LEFT JOIN admin_users au ON au.id = er.ingresado_por + WHERE er.item_id IN ({$inPh}) + ORDER BY er.item_id, er.id + "); + $stmt->execute($itemIds); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $res) { + $resultadosPorItem[(int) $res['item_id']][] = $res; + } +} + +// Agregar resultados a cada ítem +foreach ($items as &$item) { + $item['resultados'] = $resultadosPorItem[(int) $item['id']] ?? []; +} +unset($item); + +// Muestras +$stmt = $pdo->prepare(' + SELECT em.*, au.nombre AS tomada_nombre + FROM exam_muestras em + LEFT JOIN admin_users au ON au.id = em.tomada_por + WHERE em.orden_id = ? + ORDER BY em.id +'); +$stmt->execute([$id]); +$muestras = $stmt->fetchAll(PDO::FETCH_ASSOC); + +jsonOk([ + 'data' => array_merge($orden, [ + 'items' => $items, + 'muestras' => $muestras, + ]), +]); diff --git a/modules/registro_exams/api/get_ordenes.php b/modules/registro_exams/api/get_ordenes.php new file mode 100644 index 0000000..44a033d --- /dev/null +++ b/modules/registro_exams/api/get_ordenes.php @@ -0,0 +1,102 @@ +prepare( + "SELECT COUNT(*) FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + {$whereSQL}" +); +$countStmt->execute($params); +$total = (int) $countStmt->fetchColumn(); + +// Datos +$sql = " + SELECT + o.id, o.codigo, o.prioridad, o.estado, o.requiere_ayuno, o.horas_ayuno, + o.medico_nombre, o.diagnostico, o.notas, + o.creado_at, o.updated_at, + p.id AS paciente_id, + p.nombre_completo, + p.numero_documento, + p.tipo_documento, + p.telefono, + p.fecha_nacimiento, + (SELECT COUNT(*) FROM exam_items ei WHERE ei.orden_id = o.id) AS total_items, + (SELECT COUNT(*) FROM exam_items ei WHERE ei.orden_id = o.id AND ei.estado = 'resultado_listo') AS items_listos, + (SELECT COUNT(*) FROM exam_muestras em WHERE em.orden_id = o.id AND em.estado = 'tomada') AS muestras_tomadas + FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + {$whereSQL} + ORDER BY + FIELD(o.prioridad, 'stat', 'urgente', 'normal'), + o.creado_at ASC + LIMIT {$limit} OFFSET {$offset} +"; + +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$ordenes = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// Calcular edad en años para cada paciente +foreach ($ordenes as &$row) { + if ($row['fecha_nacimiento']) { + $diff = (new DateTime($row['fecha_nacimiento']))->diff(new DateTime()); + $row['edad'] = $diff->y; + } else { + $row['edad'] = null; + } +} +unset($row); + +jsonOk([ + 'data' => $ordenes, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'pages' => (int) ceil($total / $limit), + 'fecha' => $fecha, +]); diff --git a/modules/registro_exams/api/save_orden.php b/modules/registro_exams/api/save_orden.php new file mode 100644 index 0000000..f472e12 --- /dev/null +++ b/modules/registro_exams/api/save_orden.php @@ -0,0 +1,148 @@ +, codigo: "EX-YYYYMMDD-NNNN" } + */ + +require_once __DIR__ . '/_helpers.php'; +requireMethod('POST'); +requireExams(); + +$body = inputJson(); + +// ── Validaciones ────────────────────────────────────────────── +$pacienteId = isset($body['paciente_id']) ? (int) $body['paciente_id'] : 0; +if ($pacienteId <= 0) { + jsonError('Debe indicar el paciente (paciente_id)'); +} + +$items = isset($body['items']) && is_array($body['items']) + ? array_map('intval', $body['items']) + : []; + +// Filtrar zeros +$items = array_values(array_filter($items, fn($v) => $v > 0)); +if (count($items) === 0) { + jsonError('Debe agregar al menos un examen'); +} + +// Normalizar prioridad +$prioridadesPermitidas = ['normal', 'urgente', 'stat']; +$prioridad = in_array($body['prioridad'] ?? '', $prioridadesPermitidas, true) + ? $body['prioridad'] : 'normal'; + +$ordenId = isset($body['id']) && $body['id'] ? (int) $body['id'] : null; +$solicitudId = isset($body['solicitud_id']) ? ((int) $body['solicitud_id'] ?: null) : null; +$ordenMedId = isset($body['orden_medica_id']) ? ((int) $body['orden_medica_id'] ?: null) : null; +$medicoNombre = trim($body['medico_nombre'] ?? ''); +$medicoReg = trim($body['medico_registro'] ?? ''); +$diagnostico = trim($body['diagnostico'] ?? ''); +$notas = trim($body['notas'] ?? ''); + +$pdo = db(); + +try { + $pdo->beginTransaction(); + + if ($ordenId === null) { + // ── CREAR nueva orden ────────────────────────────────── + $codigo = generarCodigoOrden(); + + $stmt = $pdo->prepare(' + INSERT INTO exam_ordenes + (paciente_id, solicitud_id, orden_medica_id, codigo, + medico_nombre, medico_registro, diagnostico, + prioridad, notas, creado_por, creado_at) + VALUES + (?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, NOW()) + '); + $stmt->execute([ + $pacienteId, $solicitudId, $ordenMedId, $codigo, + $medicoNombre ?: null, $medicoReg ?: null, $diagnostico ?: null, + $prioridad, $notas ?: null, adminId(), + ]); + $ordenId = (int) $pdo->lastInsertId(); + + } else { + // ── ACTUALIZAR orden existente ───────────────────────── + $stmt = $pdo->prepare(' + UPDATE exam_ordenes SET + medico_nombre = ?, + medico_registro = ?, + diagnostico = ?, + prioridad = ?, + notas = ?, + updated_at = NOW() + WHERE id = ? + '); + $stmt->execute([ + $medicoNombre ?: null, $medicoReg ?: null, $diagnostico ?: null, + $prioridad, $notas ?: null, $ordenId, + ]); + + // Leer código actual para devolverlo + $stmt2 = $pdo->prepare('SELECT codigo FROM exam_ordenes WHERE id = ?'); + $stmt2->execute([$ordenId]); + $codigo = $stmt2->fetchColumn() ?: ''; + } + + // ── Sincronizar ítems ────────────────────────────────────── + // Obtener tipos ya existentes en la orden + $stmtEx = $pdo->prepare( + 'SELECT exam_tipo_id FROM exam_items WHERE orden_id = ?' + ); + $stmtEx->execute([$ordenId]); + $existentes = array_column($stmtEx->fetchAll(PDO::FETCH_ASSOC), 'exam_tipo_id'); + $existentes = array_map('intval', $existentes); + + // Insertar los que no existen aún + $nuevos = array_diff($items, $existentes); + if ($nuevos) { + $stmtIns = $pdo->prepare( + 'INSERT INTO exam_items (orden_id, exam_tipo_id) VALUES (?, ?)' + ); + foreach ($nuevos as $tipoId) { + $stmtIns->execute([$ordenId, $tipoId]); + } + } + + // Eliminar los que ya no están en la lista (solo si aún están pendientes) + $eliminar = array_diff($existentes, $items); + if ($eliminar) { + $inPlaceholders = implode(',', array_fill(0, count($eliminar), '?')); + $stmtDel = $pdo->prepare( + "DELETE FROM exam_items + WHERE orden_id = ? + AND exam_tipo_id IN ({$inPlaceholders}) + AND estado = 'pendiente'" + ); + $stmtDel->execute(array_merge([$ordenId], array_values($eliminar))); + } + + $pdo->commit(); + + jsonOk(['id' => $ordenId, 'codigo' => $codigo], 'Orden guardada correctamente'); + +} catch (\Throwable $e) { + $pdo->rollBack(); + jsonError('Error al guardar la orden: ' . $e->getMessage(), 500); +} diff --git a/modules/registro_exams/api/save_resultado.php b/modules/registro_exams/api/save_resultado.php new file mode 100644 index 0000000..b845a31 --- /dev/null +++ b/modules/registro_exams/api/save_resultado.php @@ -0,0 +1,121 @@ +prepare('SELECT id, orden_id FROM exam_items WHERE id = ?'); +$stmt->execute([$itemId]); +$item = $stmt->fetch(PDO::FETCH_ASSOC); +if (!$item) { + jsonError('Ítem no encontrado', 404); +} + +try { + $pdo->beginTransaction(); + + // Borrar resultados anteriores del ítem (reemplazo completo) + $pdo->prepare('DELETE FROM exam_resultados WHERE item_id = ?')->execute([$itemId]); + + $ins = $pdo->prepare(' + INSERT INTO exam_resultados + (item_id, campo_nombre, valor_texto, valor_numerico, + unidad, referencia_min, referencia_max, referencia_texto, + es_anormal, ingresado_por) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + + $inserted = 0; + foreach ($resultados as $r) { + $campo = trim($r['campo_nombre'] ?? ''); + if ($campo === '') { + continue; + } + $ins->execute([ + $itemId, + $campo, + isset($r['valor_texto']) && $r['valor_texto'] !== '' ? $r['valor_texto'] : null, + isset($r['valor_numerico']) && $r['valor_numerico'] !== null ? (float) $r['valor_numerico'] : null, + isset($r['unidad']) && $r['unidad'] !== '' ? $r['unidad'] : null, + isset($r['referencia_min']) && $r['referencia_min'] !== null ? (float) $r['referencia_min'] : null, + isset($r['referencia_max']) && $r['referencia_max'] !== null ? (float) $r['referencia_max'] : null, + isset($r['referencia_texto']) && $r['referencia_texto'] !== '' ? $r['referencia_texto'] : null, + empty($r['es_anormal']) ? 0 : 1, + adminId(), + ]); + $inserted++; + } + + if ($inserted === 0) { + $pdo->rollBack(); + jsonError('No se guardó ningún resultado (verifique los campos)'); + } + + // Actualizar estado del ítem a resultado_listo + $pdo->prepare( + "UPDATE exam_items SET estado = 'resultado_listo' WHERE id = ?" + )->execute([$itemId]); + + // Verificar si todos los ítems de la orden están listos para cerrarla + $stmtCheck = $pdo->prepare( + "SELECT COUNT(*) FROM exam_items + WHERE orden_id = ? + AND estado NOT IN ('resultado_listo','entregado')" + ); + $stmtCheck->execute([$item['orden_id']]); + $pendientes = (int) $stmtCheck->fetchColumn(); + + if ($pendientes === 0) { + $pdo->prepare( + "UPDATE exam_ordenes SET estado = 'completa', updated_at = NOW() WHERE id = ?" + )->execute([$item['orden_id']]); + } + + $pdo->commit(); + + jsonOk(['inserted' => $inserted], 'Resultados guardados'); + +} catch (\Throwable $e) { + $pdo->rollBack(); + jsonError('Error al guardar resultados: ' . $e->getMessage(), 500); +} diff --git a/modules/registro_exams/module.php b/modules/registro_exams/module.php new file mode 100644 index 0000000..148c122 --- /dev/null +++ b/modules/registro_exams/module.php @@ -0,0 +1,21 @@ + 'registro_exams', + 'name' => 'Registro Exámenes', + 'icon' => 'fas fa-vials', + 'category' => 'clinico', + 'route' => '/erp.php?m=registro_exams&v=lista', + 'is_active' => true, + 'sort_order' => 60, + 'oleada' => 2, + 'description' => 'Registro de exámenes de laboratorio en sede. ' + . 'Creación de órdenes, toma de muestras e ingreso de resultados.', + 'links' => [ + ['label' => 'Órdenes del día', 'icon' => 'fas fa-list-check', 'url' => '/erp.php?m=registro_exams&v=lista'], + ['label' => 'Nueva orden', 'icon' => 'fas fa-plus-circle', 'url' => '/erp.php?m=registro_exams&v=nueva_orden'], + ], +]; diff --git a/modules/registro_exams/views/etiqueta.php b/modules/registro_exams/views/etiqueta.php new file mode 100644 index 0000000..88e6a49 --- /dev/null +++ b/modules/registro_exams/views/etiqueta.php @@ -0,0 +1,325 @@ +getConnection(); + + $stmt = $pdo->prepare(' + SELECT + o.id, o.codigo, o.prioridad, o.estado, + o.requiere_ayuno, o.horas_ayuno, + o.medico_nombre, o.diagnostico, o.creado_at, + p.nombre_completo, p.numero_documento, p.tipo_documento, + p.fecha_nacimiento, p.eps + FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + WHERE o.id = ? + '); + $stmt->execute([$ordenId]); + $orden = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$orden) { + http_response_code(404); + die('Orden no encontrada.'); + } + + // Ítems + $stmt = $pdo->prepare(' + SELECT ei.estado, et.nombre AS tipo_nombre, et.codigo AS tipo_codigo, + et.requiere_ayuno, et.horas_ayuno + FROM exam_items ei + JOIN exam_tipos et ON et.id = ei.exam_tipo_id + WHERE ei.orden_id = ? + ORDER BY et.nombre + '); + $stmt->execute([$ordenId]); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Edad + $edad = null; + if ($orden['fecha_nacimiento']) { + $edad = (new DateTime($orden['fecha_nacimiento']))->diff(new DateTime())->y; + } + + // Ayuno requerido (por la orden o por algún ítem) + $ayunoReq = (bool) $orden['requiere_ayuno']; + foreach ($items as $it) { + if ($it['requiere_ayuno']) { $ayunoReq = true; break; } + } + $horasValues = array_filter(array_column($items, 'horas_ayuno')); + $horasAyuno = $orden['horas_ayuno'] ?? ($horasValues ? max($horasValues) : 0); + +} catch (\Throwable $e) { + http_response_code(500); + die('Error al cargar datos: ' . htmlspecialchars($e->getMessage())); +} + +$fecha = $orden['creado_at'] + ? (new DateTime($orden['creado_at']))->format('d/m/Y H:i') + : date('d/m/Y'); + +// Generar código de barras como CSS barcode (sin librería externa) +// Usaremos simplemente el código textual + formato visual +$codigoBarras = $orden['codigo']; +?> + + +
+ +| Código | +Paciente | +Edad | +Prioridad | +Estado | +Ítems | +Muestras | +Hora | +Acciones | +
|---|---|---|---|---|---|---|---|---|
| + Cargando… + | ||||||||
+ He leído y comprendido el contenido de este documento. + Por favor dibuje su firma en el recuadro: +
+ +