diff --git a/migrations/003_turnero.sql b/migrations/003_turnero.sql new file mode 100644 index 0000000..aec3671 --- /dev/null +++ b/migrations/003_turnero.sql @@ -0,0 +1,254 @@ +-- ============================================================ +-- Migración 003 – Módulo Turnero (Oleada 1) +-- Sistema de Turnero Inteligente con consentimientos informados +-- FASE 3.1 del plan ERP multi-módulo. +-- Segura para re-ejecutar (IF NOT EXISTS / INSERT IGNORE). +-- ============================================================ + +-- ── 1. Catálogo maestro de tipos de examen ────────────────── +-- Compartido con Oleada 2 (registro_exams). +-- En Migración 005 se agregan precio_base, requiere_ayunas, instrucciones. +CREATE TABLE IF NOT EXISTS exam_tipos ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + codigo VARCHAR(20) NOT NULL COMMENT 'Ej: HEM, GLU, URIN', + nombre VARCHAR(150) NOT NULL COMMENT 'Ej: Hemograma completo', + categoria VARCHAR(80) DEFAULT NULL COMMENT 'Ej: Hematología, Química', + activo TINYINT(1) NOT NULL DEFAULT 1, + creado_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_codigo (codigo), + INDEX idx_activo (activo), + INDEX idx_categoria (categoria) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Catálogo maestro de tipos de examen del laboratorio'; + +-- ── 2. Relación M:N examen ↔ consentimiento (formulario) ──── +-- Si un examen no tiene registro aquí, no se exige consentimiento. +-- Varios exámenes del mismo turno que apunten al mismo formulario_id +-- generan UN SOLO consentimiento (deduplicado en la lógica PHP). +CREATE TABLE IF NOT EXISTS exam_tipo_consentimientos ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + exam_tipo_id INT UNSIGNED NOT NULL, + formulario_id INT NOT NULL COMMENT 'FK lab_formularios.id (tipo=consentimiento)', + PRIMARY KEY (id), + UNIQUE KEY uq_examen_form (exam_tipo_id, formulario_id), + INDEX idx_formulario (formulario_id), + CONSTRAINT fk_etc_exam FOREIGN KEY (exam_tipo_id) REFERENCES exam_tipos (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='M:N entre tipos de examen y formularios de consentimiento'; + +-- ── 3. Lugares / estaciones de servicio configurables ─────── +-- Recepción NO es un lugar; es el paso implícito del flujo. +-- Cada lugar tiene su propia cola y su propia pantalla TV. +CREATE TABLE IF NOT EXISTS turnero_lugares ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + nombre VARCHAR(100) NOT NULL COMMENT 'Ej: Toma de Muestras 1, Rayos X', + descripcion TEXT DEFAULT NULL, + activo TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 99 COMMENT 'Orden en pantalla TV general', + creado_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_activo (activo), + INDEX idx_sort (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Estaciones de servicio configurables del turnero'; + +-- Lugares de ejemplo (ajustar según el laboratorio real) +INSERT IGNORE INTO turnero_lugares (id, nombre, descripcion, activo, sort_order) VALUES +(1, 'Toma de Muestras 1', 'Primera estación de toma de muestras', 1, 1), +(2, 'Toma de Muestras 2', 'Segunda estación de toma de muestras', 1, 2), +(3, 'Rayos X', 'Área de radiología', 1, 3); + +-- ── 4. Catálogo de prioridades (códigos A-F) ───────────────── +-- Editable desde el panel admin. orden_peso menor = mayor prioridad. +CREATE TABLE IF NOT EXISTS turnero_prioridades ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + codigo CHAR(1) NOT NULL COMMENT 'A, B, C, D, E, F', + nombre VARCHAR(100) NOT NULL, + orden_peso INT NOT NULL COMMENT 'Menor número = mayor prioridad', + color VARCHAR(7) NOT NULL DEFAULT '#6b7280' COMMENT 'Hex color para UI', + activo TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uq_codigo (codigo), + INDEX idx_orden (orden_peso) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Catálogo configurable de prioridades de atención'; + +-- Prioridades por defecto A-F +INSERT IGNORE INTO turnero_prioridades (codigo, nombre, orden_peso, color, activo) VALUES +('A', 'Niños', 1, '#ef4444', 1), +('B', 'Embarazadas', 2, '#f97316', 1), +('C', 'Adulto mayor', 3, '#eab308', 1), +('D', 'Discapacidad', 4, '#8b5cf6', 1), +('E', 'Paciente general', 5, '#3b82f6', 1), +('F', 'Muestra pendiente', 6, '#6b7280', 1); + +-- ── 5. Sesiones diarias de atención ───────────────────────── +CREATE TABLE IF NOT EXISTS turnero_sesiones ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + fecha DATE NOT NULL, + abierto_por INT DEFAULT NULL COMMENT 'FK admin_users.id', + cerrado_por INT DEFAULT NULL COMMENT 'FK admin_users.id', + inicio_at DATETIME DEFAULT NULL, + fin_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_fecha (fecha), + INDEX idx_fecha (fecha) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Sesión de atención del día (una por fecha)'; + +-- ── 6. Turnos ──────────────────────────────────────────────── +-- Motor principal del módulo. Un turno = un paciente en una visita. +CREATE TABLE IF NOT EXISTS turnero_turnos ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + sesion_id INT UNSIGNED NOT NULL, + numero INT NOT NULL COMMENT 'Correlativo dentro de la sesión', + codigo VARCHAR(10) NOT NULL COMMENT 'Ej: A001, E042', + prioridad_id INT UNSIGNED NOT NULL, + paciente_nombre VARCHAR(150) DEFAULT NULL COMMENT 'Capturado en kiosko (opcional)', + paciente_cel VARCHAR(20) DEFAULT NULL, + estado ENUM( + 'espera', + 'en_recepcion', + 'en_espera_lugar', + 'en_servicio', + 'finalizado', + 'ausente', + 'cancelado' + ) NOT NULL DEFAULT 'espera', + lugar_destino_id INT UNSIGNED DEFAULT NULL COMMENT 'Asignado por recepción', + -- Timestamps por etapa + creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + llamado_recepcion_at DATETIME DEFAULT NULL, + inicio_recepcion_at DATETIME DEFAULT NULL, + fin_recepcion_at DATETIME DEFAULT NULL, + llamado_lugar_at DATETIME DEFAULT NULL, + inicio_lugar_at DATETIME DEFAULT NULL, + fin_lugar_at DATETIME DEFAULT NULL, + -- Responsable de atención + atendido_recepcion_por INT DEFAULT NULL COMMENT 'FK admin_users.id', + atendido_lugar_por INT DEFAULT NULL COMMENT 'FK admin_users.id', + PRIMARY KEY (id), + UNIQUE KEY uq_sesion_numero (sesion_id, numero), + INDEX idx_sesion_estado (sesion_id, estado), + INDEX idx_lugar_estado (lugar_destino_id, estado), + INDEX idx_prioridad (prioridad_id), + INDEX idx_creado (creado_at), + CONSTRAINT fk_tt_sesion FOREIGN KEY (sesion_id) REFERENCES turnero_sesiones (id) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT fk_tt_prioridad FOREIGN KEY (prioridad_id) REFERENCES turnero_prioridades (id) ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT fk_tt_lugar FOREIGN KEY (lugar_destino_id) REFERENCES turnero_lugares (id) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Turnos del sistema de atención'; + +-- ── 7. Solicitud interna (crea recepción) ──────────────────── +-- Registra paciente, exámenes, pago y lugar destino. +-- Una solicitud por turno (UNIQUE turno_id). +CREATE TABLE IF NOT EXISTS turnero_solicitudes ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + turno_id INT UNSIGNED NOT NULL, + paciente_id INT NOT NULL COMMENT 'FK lab_pacientes.id', + lugar_id INT UNSIGNED NOT NULL COMMENT 'FK turnero_lugares.id', + total_cobrado DECIMAL(10,2) DEFAULT NULL, + metodo_pago ENUM( + 'efectivo', + 'transferencia', + 'tarjeta', + 'eps', + 'cortesia' + ) DEFAULT NULL, + observaciones TEXT DEFAULT NULL, + creado_por INT NOT NULL COMMENT 'FK admin_users.id', + creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_turno (turno_id), + INDEX idx_paciente (paciente_id), + INDEX idx_lugar (lugar_id), + INDEX idx_creado (creado_at), + CONSTRAINT fk_ts_turno FOREIGN KEY (turno_id) REFERENCES turnero_turnos (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ts_lugar FOREIGN KEY (lugar_id) REFERENCES turnero_lugares (id) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Solicitud interna de agendamiento creada en recepción'; + +-- ── 8. Exámenes de la solicitud ────────────────────────────── +CREATE TABLE IF NOT EXISTS turnero_examen_items ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + solicitud_id INT UNSIGNED NOT NULL, + exam_tipo_id INT UNSIGNED NOT NULL, + creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_solicitud (solicitud_id), + INDEX idx_exam_tipo (exam_tipo_id), + CONSTRAINT fk_tei_solicitud FOREIGN KEY (solicitud_id) REFERENCES turnero_solicitudes (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_tei_exam FOREIGN KEY (exam_tipo_id) REFERENCES exam_tipos (id) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Exámenes individuales de cada solicitud de turno'; + +-- ── 9. Consentimientos por turno (deduplicados) ────────────── +-- Un registro por formulario distinto requerido en el turno. +-- token = enlace único de firma (UUID). +CREATE TABLE IF NOT EXISTS turnero_consentimientos ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + turno_id INT UNSIGNED NOT NULL, + formulario_id INT NOT NULL COMMENT 'FK lab_formularios.id', + token VARCHAR(64) NOT NULL COMMENT 'UUID único para enlace de firma', + estado ENUM( + 'pendiente', + 'enviado', + 'visto', + 'firmado', + 'rechazado' + ) NOT NULL DEFAULT 'pendiente', + enviado_at DATETIME DEFAULT NULL, + firmado_at DATETIME DEFAULT NULL, + ip_firma VARCHAR(45) DEFAULT NULL COMMENT 'IPv4 o IPv6 del firmante', + ua_firma VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent del dispositivo', + version_formulario INT DEFAULT NULL COMMENT 'Snapshot de versión al firmar', + PRIMARY KEY (id), + UNIQUE KEY uq_token (token), + UNIQUE KEY uq_turno_form (turno_id, formulario_id), + INDEX idx_turno (turno_id), + INDEX idx_estado (estado), + CONSTRAINT fk_tc_turno FOREIGN KEY (turno_id) REFERENCES turnero_turnos (id) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Consentimientos informados vinculados a un turno (uno por formulario distinto)'; + +-- ── 10. Columna tipo en lab_formularios ────────────────────── +-- Permite marcar un formulario como consentimiento informado. +ALTER TABLE lab_formularios + ADD COLUMN IF NOT EXISTS tipo + ENUM('formulario', 'consentimiento') NOT NULL DEFAULT 'formulario' + COMMENT 'Tipo de formulario: genérico o consentimiento informado'; + +-- Índice para filtrar rápido en las consultas del turnero +ALTER TABLE lab_formularios + ADD INDEX IF NOT EXISTS idx_tipo (tipo); + +-- ── 11. Asignar módulo turnero a roles ─────────────────────── +-- Activar en system_modules si estaba inactivo +UPDATE system_modules + SET is_active = 1 + WHERE slug = 'turnero'; + +-- Permisos del recepcionista sobre el módulo turnero +INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export) +SELECT r.id, 'turnero', 1, 1, 1, 0, 0 +FROM roles r WHERE r.slug = 'recepcionista'; + +-- Permisos del bacteriólogo sobre el módulo turnero (solo lectura + edición de servicio) +INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export) +SELECT r.id, 'turnero', 1, 0, 1, 0, 0 +FROM roles r WHERE r.slug = 'bacteriologo'; + +-- ── 12. Datos de ejemplo de tipos de examen ────────────────── +-- Solo si la tabla está vacía (evita duplicar en re-ejecución) +INSERT IGNORE INTO exam_tipos (codigo, nombre, categoria) VALUES +('HEM', 'Hemograma completo', 'Hematología'), +('GLU', 'Glucosa en ayunas', 'Química'), +('URIN', 'Uroanálisis completo', 'Urología'), +('UROCUL','Urocultivo', 'Microbiología'), +('COLEST','Perfil lipídico (colesterol)', 'Química'), +('TSH', 'TSH (Función tiroidea)', 'Endocrinología'), +('HIV', 'Prueba VIH', 'Serología'), +('HEPB', 'Hepatitis B (HBsAg)', 'Serología'), +('RXP', 'Rayos X de pecho', 'Radiología'), +('ECG', 'Electrocardiograma', 'Cardiología'); diff --git a/modules/turnero/module.php b/modules/turnero/module.php new file mode 100644 index 0000000..daea2cf --- /dev/null +++ b/modules/turnero/module.php @@ -0,0 +1,23 @@ + 'turnero', + 'name' => 'Turnero', + 'icon' => 'fas fa-ticket-alt', + 'category' => 'turnero', + 'route' => '/erp.php?m=turnero&v=dashboard', + 'is_active' => true, + 'sort_order' => 40, + 'oleada' => 1, + 'description' => 'Sistema de turnos presenciales en recepción con prioridades, consentimientos y pantallas TV', + 'links' => [ + ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'], + ['name' => 'Recepción', 'icon' => 'fas fa-concierge-bell', 'route' => '/erp.php?m=turnero&v=recepcion'], + ['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'], + ['name' => 'Kiosko', 'icon' => 'fas fa-desktop', 'route' => '/erp.php?m=turnero&v=kiosko'], + ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display'], + ], +]; diff --git a/run_003_turnero.php b/run_003_turnero.php new file mode 100644 index 0000000..28834a5 --- /dev/null +++ b/run_003_turnero.php @@ -0,0 +1,163 @@ +getConnection(); + + $sql = file_get_contents($sqlFile); + $statements = []; + + // Separar sentencias por ; respetando comentarios de línea y bloques + $lines = explode("\n", $sql); + $current = ''; + + foreach ($lines as $line) { + $trimmed = trim($line); + + // Saltar líneas de comentario + if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) { + continue; + } + + $current .= ' ' . $trimmed; + + if (str_ends_with($trimmed, ';')) { + $stmt = trim($current); + if (!empty($stmt) && $stmt !== ';') { + $statements[] = $stmt; + } + $current = ''; + } + } + + echo "\n🚀 Ejecutando Migración 003 — Turnero\n"; + echo str_repeat('─', 60) . "\n\n"; + + $ok = 0; + $err = 0; + + foreach ($statements as $stmt) { + $preview = substr(preg_replace('/\s+/', ' ', $stmt), 0, 90); + + try { + $pdo->exec($stmt); + echo " ✓ " . $preview . "\n"; + $ok++; + } catch (PDOException $e) { + $msg = $e->getMessage(); + + // Errores ignorables: duplicado, ya existe, columna ya existe + $ignorable = ( + str_contains($msg, 'Duplicate entry') || + str_contains($msg, 'already exists') || + str_contains($msg, "Duplicate key name")|| + str_contains($msg, 'errno: 1050') || // table exists + str_contains($msg, 'errno: 1060') || // column exists + str_contains($msg, 'errno: 1061') // key exists + ); + + if ($ignorable) { + echo " ℹ️ (ya existía) " . $preview . "\n"; + $ok++; + } else { + echo " ✗ ERROR: " . $msg . "\n"; + echo " SQL: " . $preview . "\n\n"; + $err++; + } + } + } + + echo "\n" . str_repeat('─', 60) . "\n"; + echo "✅ Completado: $ok OK"; + if ($err) { + echo " | ⚠️ $err errores"; + } + echo "\n\n"; + + // ── Verificación post-migración ────────────────────────── + echo "📋 Verificación de tablas creadas:\n"; + + $tablas = [ + 'exam_tipos', 'exam_tipo_consentimientos', + 'turnero_lugares', 'turnero_prioridades', + 'turnero_sesiones', 'turnero_turnos', + 'turnero_solicitudes', 'turnero_examen_items', + 'turnero_consentimientos', + ]; + + foreach ($tablas as $tabla) { + try { + $count = $pdo->query("SELECT COUNT(*) FROM `$tabla`")->fetchColumn(); + echo " ✓ $tabla ($count filas)\n"; + } catch (PDOException $e) { + echo " ✗ $tabla — NO ENCONTRADA\n"; + } + } + + // Verificar columna tipo en lab_formularios + try { + $cols = $pdo->query("SHOW COLUMNS FROM lab_formularios LIKE 'tipo'")->fetchAll(); + if (!empty($cols)) { + echo " ✓ lab_formularios.tipo (ENUM)\n"; + } else { + echo " ✗ lab_formularios.tipo — NO SE AGREGÓ\n"; + } + } catch (PDOException $e) { + echo " ✗ lab_formularios — " . $e->getMessage() . "\n"; + } + + // Verificar prioridades + echo "\n🎫 Prioridades insertadas:\n"; + $prios = $pdo->query("SELECT codigo, nombre, color FROM turnero_prioridades ORDER BY orden_peso")->fetchAll(PDO::FETCH_ASSOC); + foreach ($prios as $p) { + echo " [{$p['codigo']}] {$p['nombre']} {$p['color']}\n"; + } + + // Verificar lugares + echo "\n📍 Lugares creados:\n"; + $lugares = $pdo->query("SELECT id, nombre FROM turnero_lugares ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC); + foreach ($lugares as $l) { + echo " [{$l['id']}] {$l['nombre']}\n"; + } + + // Verificar exámenes de ejemplo + echo "\n🧪 Tipos de examen de ejemplo:\n"; + $exams = $pdo->query("SELECT codigo, nombre, categoria FROM exam_tipos ORDER BY id")->fetchAll(PDO::FETCH_ASSOC); + foreach ($exams as $e) { + echo " [{$e['codigo']}] {$e['nombre']} — {$e['categoria']}\n"; + } + + echo "\n✅ Migración 003 lista. Continúa con FASE 3.2 (Motor de cola + API).\n\n"; + +} catch (Exception $e) { + echo "❌ Error fatal: " . $e->getMessage() . "\n"; + exit(1); +}