Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/cli/exportar_todo.rb
|
||||
# ==========================================
|
||||
# Exporta TODAS las bitácoras de la DB a archivos .md
|
||||
# Uso: ruby adn/tools/db/cli/exportar_todo.rb [--dir DIRECTORIO]
|
||||
# — o vía enjambre —
|
||||
# ./adn/tools/run db exportar:md --todo
|
||||
|
||||
require 'date'
|
||||
require 'optparse'
|
||||
require_relative '../core/bitacora_db'
|
||||
require_relative '../exporters/md_exporter'
|
||||
|
||||
module BitacorasDB
|
||||
module CLI
|
||||
class ExportarTodo
|
||||
def initialize(bitacoras_dir, logger = nil)
|
||||
@bitacoras_dir = bitacoras_dir
|
||||
@logger = logger || default_logger
|
||||
@resultados = { ok: 0, error: 0 }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
@logger.info("Exportando todas las bitácoras de DB a MD...")
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
bitacoras = db.list_bitacoras(limit: 500)
|
||||
|
||||
if bitacoras.empty?
|
||||
@logger.info("No hay bitácoras en la base de datos.")
|
||||
return
|
||||
end
|
||||
|
||||
exporter = BitacorasDB::Exporters::MDExporter.new(db, @logger)
|
||||
|
||||
bitacoras.each do |b|
|
||||
fecha = b['fecha'].to_s
|
||||
fecha = fecha.split('T').first if fecha.include?('T')
|
||||
archivo = File.join(@bitacoras_dir, "#{fecha}.md")
|
||||
|
||||
if exporter.exportar_bitacora(fecha, archivo)
|
||||
@resultados[:ok] += 1
|
||||
else
|
||||
@resultados[:error] += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@logger.info("📊 Exportación masiva completada:")
|
||||
@logger.info(" ✅ Exitosas: #{@resultados[:ok]}")
|
||||
@logger.info(" ❌ Errores: #{@resultados[:error]}") if @resultados[:error] > 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_logger
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
def exito(msg); puts "[OK] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ejecución directa
|
||||
if __FILE__ == $0
|
||||
bitacoras_dir = File.expand_path('../../../docs/bitacoras', __dir__)
|
||||
BitacorasDB::CLI::ExportarTodo.new(bitacoras_dir).ejecutar
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/cli/verificar_migracion.rb
|
||||
# ================================================
|
||||
# Verifica que todas las bitácoras MD existentes estén correctamente
|
||||
# migradas en la base de datos. Reporta faltantes y discrepancias.
|
||||
#
|
||||
# Uso:
|
||||
# ruby adn/tools/db/cli/verificar_migracion.rb
|
||||
# — o vía enjambre —
|
||||
# ./adn/tools/run db verificar
|
||||
|
||||
require 'date'
|
||||
require_relative '../core/bitacora_db'
|
||||
|
||||
module BitacorasDB
|
||||
module CLI
|
||||
class VerificarMigracion
|
||||
COLORES = {
|
||||
reset: "\e[0m", bold: "\e[1m", red: "\e[31m",
|
||||
green: "\e[32m", yellow: "\e[33m", cyan: "\e[36m", dim: "\e[2m"
|
||||
}.freeze
|
||||
|
||||
def initialize(bitacoras_dir)
|
||||
@bitacoras_dir = bitacoras_dir
|
||||
@resultados = { ok: [], sin_entradas: [], no_migrada: [], sin_md: [] }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
puts "#{COLORES[:cyan]}🔍 Verificación de migración MD ↔ DB#{COLORES[:reset]}\n\n"
|
||||
|
||||
archivos_md = Dir.glob(File.join(@bitacoras_dir, '*.md')).sort
|
||||
puts "#{COLORES[:dim]}Archivos MD encontrados: #{archivos_md.size}#{COLORES[:reset]}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# 1. Validar cada MD contra la DB
|
||||
archivos_md.each do |archivo|
|
||||
fecha = File.basename(archivo, '.md')
|
||||
bitacora = db.find_bitacora_by_fecha(fecha)
|
||||
|
||||
unless bitacora
|
||||
@resultados[:no_migrada] << fecha
|
||||
next
|
||||
end
|
||||
|
||||
entradas = db.execute(
|
||||
"SELECT COUNT(*) as total FROM bitacoras.entradas WHERE bitacora_id = $1",
|
||||
[bitacora['id']]
|
||||
)
|
||||
total = entradas.first['total'].to_i
|
||||
|
||||
if total > 0
|
||||
@resultados[:ok] << { fecha: fecha, entradas: total }
|
||||
else
|
||||
@resultados[:sin_entradas] << fecha
|
||||
end
|
||||
end
|
||||
|
||||
# 2. Bitácoras en DB que no tienen MD
|
||||
todas_db = db.list_bitacoras(limit: 500)
|
||||
fechas_md = archivos_md.map { |a| File.basename(a, '.md') }
|
||||
todas_db.each do |b|
|
||||
f = b['fecha'].to_s
|
||||
f = f.split('T').first if f.include?('T') # por si viene con timestamp
|
||||
@resultados[:sin_md] << f unless fechas_md.include?(f)
|
||||
end
|
||||
end
|
||||
|
||||
mostrar_resultados
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_resultados
|
||||
puts "\n#{COLORES[:bold]}📊 Resultado de verificación:#{COLORES[:reset]}\n"
|
||||
|
||||
# OK
|
||||
puts "#{COLORES[:green]}✅ Migradas correctamente: #{@resultados[:ok].size}#{COLORES[:reset]}"
|
||||
@resultados[:ok].each do |r|
|
||||
puts " #{COLORES[:dim]}#{r[:fecha]} (#{r[:entradas]} entradas)#{COLORES[:reset]}"
|
||||
end
|
||||
|
||||
# Sin entradas
|
||||
if @resultados[:sin_entradas].any?
|
||||
puts "\n#{COLORES[:yellow]}⚠ En DB pero sin entradas: #{@resultados[:sin_entradas].size}#{COLORES[:reset]}"
|
||||
@resultados[:sin_entradas].each { |f| puts " #{f}" }
|
||||
end
|
||||
|
||||
# No migradas
|
||||
if @resultados[:no_migrada].any?
|
||||
puts "\n#{COLORES[:red]}❌ No migradas (MD existe, DB no): #{@resultados[:no_migrada].size}#{COLORES[:reset]}"
|
||||
@resultados[:no_migrada].each { |f| puts " #{f}" }
|
||||
puts " #{COLORES[:dim]}Ejecuta: ./adn/tools/run db migrar:md FECHA#{COLORES[:reset]}"
|
||||
end
|
||||
|
||||
# Sin MD
|
||||
if @resultados[:sin_md].any?
|
||||
puts "\n#{COLORES[:cyan]}ℹ En DB sin archivo MD: #{@resultados[:sin_md].size}#{COLORES[:reset]}"
|
||||
@resultados[:sin_md].each { |f| puts " #{f}" }
|
||||
puts " #{COLORES[:dim]}Ejecuta: ./adn/tools/run db exportar:md FECHA#{COLORES[:reset]}"
|
||||
end
|
||||
|
||||
total = @resultados[:ok].size + @resultados[:sin_entradas].size
|
||||
total_md = @resultados[:ok].size + @resultados[:sin_entradas].size + @resultados[:no_migrada].size
|
||||
pct = total_md > 0 ? ((@resultados[:ok].size.to_f / total_md) * 100).round(1) : 0
|
||||
|
||||
puts "\n#{COLORES[:bold]}📈 Cobertura: #{pct}% (#{@resultados[:ok].size}/#{total_md})#{COLORES[:reset]}"
|
||||
|
||||
if @resultados[:no_migrada].empty? && @resultados[:sin_entradas].empty?
|
||||
puts "#{COLORES[:green]}#{COLORES[:bold]}🎉 ¡Migración completa! Todas las bitácoras están en la DB.#{COLORES[:reset]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ejecución directa
|
||||
if __FILE__ == $0
|
||||
bitacoras_dir = File.expand_path('../../../docs/bitacoras', __dir__)
|
||||
BitacorasDB::CLI::VerificarMigracion.new(bitacoras_dir).ejecutar
|
||||
end
|
||||
@@ -0,0 +1,839 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/bitacora_db.rb — Clase principal para conexión a PostgreSQL
|
||||
# ================================================================================
|
||||
# Esta clase proporciona una interfaz Ruby para interactuar con la base de datos
|
||||
# dtic-BITACORAs. Maneja conexiones, operaciones CRUD y validaciones para las
|
||||
# tablas del esquema 'bitacoras':
|
||||
# • nodos - Servidores, VMs, PCs del sistema
|
||||
# • bitacoras - Bitácoras diarias (fecha única)
|
||||
# • temas - Agrupadores de entradas por nodo
|
||||
# • entradas - Entradas cronológicas en formato I-F-D-E
|
||||
#
|
||||
# Uso básico:
|
||||
# db = BitacorasDB::BitacoraDB.new
|
||||
# db.connection_test? # Verificar conexión
|
||||
# bitacoras = db.list_bitacoras(limit: 10)
|
||||
# db.create_entrada(inicio: '09:00', fin: '10:00', descripcion: 'Reunión', nodo_id: 1)
|
||||
#
|
||||
# Uso con bloque (cierre automático):
|
||||
# BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# db.create_bitacora(fecha: Date.today)
|
||||
# end
|
||||
#
|
||||
# Configuración:
|
||||
# La configuración se carga desde tools/config/database.yml
|
||||
# Entorno por defecto: development (contenedor Docker en puerto 5433)
|
||||
|
||||
require 'pg'
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
module BitacorasDB
|
||||
class BitacoraDB
|
||||
# Constantes para el esquema y tablas
|
||||
SCHEMA = 'bitacoras'
|
||||
TABLES = {
|
||||
nodos: 'nodos',
|
||||
bitacoras: 'bitacoras',
|
||||
temas: 'temas',
|
||||
entradas: 'entradas'
|
||||
}.freeze
|
||||
|
||||
# Estados válidos para entradas y temas
|
||||
ESTADOS_VALIDOS = ['⏳', '✅', '❌', '⚠️', '⏸️'].freeze
|
||||
|
||||
# Modos válidos para entradas
|
||||
MODOS_VALIDOS = ['P', 'R', 'S'].freeze # P=Presencial, R=Remoto, S=Servicio
|
||||
|
||||
# Inicializa la conexión a la base de datos
|
||||
#
|
||||
# @param env [Symbol, String] Entorno (:development, :test, :production)
|
||||
# @param config_path [String] Ruta al archivo de configuración YAML
|
||||
# @param logger [Object] Instancia de logger (por defecto ADN.logger si disponible)
|
||||
def initialize(env: :development, config_path: nil, logger: nil)
|
||||
@env = env.to_s
|
||||
@config_path = config_path || default_config_path
|
||||
@config = load_config
|
||||
@connection = nil
|
||||
@logger = logger || default_logger
|
||||
@connected = false
|
||||
end
|
||||
|
||||
# Carga la configuración desde el archivo YAML
|
||||
#
|
||||
# @return [Hash] Configuración para el entorno actual
|
||||
# @raise [RuntimeError] Si no se encuentra la configuración o el entorno
|
||||
def load_config
|
||||
unless File.exist?(@config_path)
|
||||
raise "Archivo de configuración no encontrado: #{@config_path}"
|
||||
end
|
||||
|
||||
config = YAML.load_file(@config_path)
|
||||
|
||||
unless config && config[@env]
|
||||
raise "Entorno '#{@env}' no encontrado en #{@config_path}. Entornos disponibles: #{config.keys.join(', ')}"
|
||||
end
|
||||
|
||||
config[@env]
|
||||
end
|
||||
|
||||
# Establece conexión con la base de datos
|
||||
#
|
||||
# @return [PG::Connection] Conexión PostgreSQL
|
||||
# @raise [PG::Error] Si falla la conexión
|
||||
def connect
|
||||
return @connection if @connected && @connection && !@connection.finished?
|
||||
|
||||
begin
|
||||
@logger&.info("Conectando a PostgreSQL: #{@config['host']}:#{@config['port']}/#{@config['database']}")
|
||||
|
||||
@connection = PG.connect(
|
||||
host: @config['host'],
|
||||
port: @config['port'],
|
||||
dbname: @config['database'],
|
||||
user: @config['username'],
|
||||
password: @config['password'],
|
||||
connect_timeout: @config['timeout'] || 5000
|
||||
)
|
||||
|
||||
# Configurar búsqueda de esquema
|
||||
if @config['schema_search_path']
|
||||
@connection.exec("SET search_path TO #{@config['schema_search_path']}")
|
||||
end
|
||||
|
||||
@connected = true
|
||||
@logger&.info("Conexión a PostgreSQL establecida correctamente")
|
||||
|
||||
@connection
|
||||
rescue PG::Error => e
|
||||
@connected = false
|
||||
@logger&.error("Error conectando a PostgreSQL: #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# Cierra la conexión con la base de datos
|
||||
def disconnect
|
||||
if @connected && @connection && !@connection.finished?
|
||||
@connection.close
|
||||
@connected = false
|
||||
@logger&.info("Conexión a PostgreSQL cerrada")
|
||||
end
|
||||
@connection = nil
|
||||
end
|
||||
|
||||
# Ejecuta un bloque con una conexión, cerrando automáticamente al final
|
||||
#
|
||||
# @yield [BitacoraDB] Instancia de BitacoraDB con conexión establecida
|
||||
# @return [Object] Resultado del bloque
|
||||
def with_connection(&block)
|
||||
connect
|
||||
yield self
|
||||
ensure
|
||||
disconnect
|
||||
end
|
||||
|
||||
# Método de clase para uso con bloque
|
||||
#
|
||||
# @param env [Symbol] Entorno a usar
|
||||
# @param config_path [String] Ruta al archivo de configuración
|
||||
# @yield [BitacoraDB] Instancia de BitacoraDB
|
||||
# @return [Object] Resultado del bloque
|
||||
def self.with_connection(env: :development, config_path: nil, &block)
|
||||
db = new(env: env, config_path: config_path)
|
||||
db.with_connection(&block)
|
||||
end
|
||||
|
||||
# Verifica si la conexión a la base de datos está activa
|
||||
#
|
||||
# @return [Boolean] true si la conexión es válida
|
||||
def connection_test?
|
||||
return false unless @connected && @connection && !@connection.finished?
|
||||
|
||||
begin
|
||||
@connection.exec('SELECT 1')
|
||||
true
|
||||
rescue PG::Error
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Obtiene información de salud de la base de datos
|
||||
#
|
||||
# @return [Hash] Información de estado y estadísticas
|
||||
def health_check
|
||||
return { status: 'disconnected', message: 'No hay conexión activa' } unless connection_test?
|
||||
|
||||
begin
|
||||
result = @connection.exec('SELECT version()').first
|
||||
stats = @connection.exec('SELECT COUNT(*) as total FROM bitacoras.bitacoras').first
|
||||
|
||||
{
|
||||
status: 'healthy',
|
||||
postgres_version: result['version'],
|
||||
bitacoras_count: stats['total'].to_i,
|
||||
timestamp: Time.now.iso8601,
|
||||
connection: {
|
||||
host: @config['host'],
|
||||
port: @config['port'],
|
||||
database: @config['database']
|
||||
}
|
||||
}
|
||||
rescue PG::Error => e
|
||||
{
|
||||
status: 'unhealthy',
|
||||
message: e.message,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA BITÁCORAS
|
||||
|
||||
# Crea una nueva bitácora diaria
|
||||
#
|
||||
# @param fecha [Date, String] Fecha de la bitácora
|
||||
# @param resumen [String, nil] Resumen opcional
|
||||
# @return [Hash] Bitácora creada con ID
|
||||
# @raise [PG::Error] Si ya existe bitácora para esa fecha
|
||||
def create_bitacora(fecha:, resumen: nil)
|
||||
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, fecha, resumen, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [fecha.to_s, resumen])
|
||||
result.first
|
||||
end
|
||||
|
||||
# Obtiene bitácoras con paginación y filtros
|
||||
#
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento para paginación
|
||||
# @param desde [Date, String, nil] Fecha desde
|
||||
# @param hasta [Date, String, nil] Fecha hasta
|
||||
# @return [Array<Hash>] Lista de bitácoras
|
||||
def list_bitacoras(limit: 50, offset: 0, desde: nil, hasta: nil)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if desde
|
||||
conditions << "fecha >= $#{param_index}"
|
||||
params << Date.parse(desde.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if hasta
|
||||
conditions << "fecha <= $#{param_index}"
|
||||
params << Date.parse(hasta.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
|
||||
#{where_clause}
|
||||
ORDER BY fecha DESC
|
||||
LIMIT $#{param_index} OFFSET $#{param_index + 1}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
params << offset
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# Busca una bitácora por fecha
|
||||
#
|
||||
# @param fecha [Date, String] Fecha a buscar
|
||||
# @return [Hash, nil] Bitácora encontrada o nil
|
||||
def find_bitacora_by_fecha(fecha)
|
||||
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
|
||||
WHERE fecha = $1
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [fecha.to_s])
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA NODOS
|
||||
|
||||
# Crea un nuevo nodo (servidor, VM, PC)
|
||||
#
|
||||
# @param nombre [String] Nombre único del nodo
|
||||
# @param ip [String, nil] Dirección IP
|
||||
# @param tipo [String] Tipo ('servidor', 'hipervisor', 'vm', 'pc')
|
||||
# @param descripcion [String, nil] Descripción opcional
|
||||
# @param activo [Boolean] Si el nodo está activo
|
||||
# @return [Hash] Nodo creado con ID
|
||||
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true)
|
||||
tipos_validos = ['servidor', 'hipervisor', 'vm', 'pc', 'vmware', 'xenserver', 'servicio']
|
||||
|
||||
unless tipos_validos.include?(tipo.downcase)
|
||||
raise ArgumentError, "Tipo de nodo inválido. Válidos: #{tipos_validos.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo])
|
||||
result.first
|
||||
end
|
||||
|
||||
# Lista nodos con filtros
|
||||
#
|
||||
# @param activo [Boolean, nil] Filtrar por estado activo
|
||||
# @param tipo [String, nil] Filtrar por tipo
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @return [Array<Hash>] Lista de nodos
|
||||
def list_nodos(activo: nil, tipo: nil, limit: 100)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if !activo.nil?
|
||||
conditions << "activo = $#{param_index}"
|
||||
params << activo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tipo
|
||||
conditions << "tipo = $#{param_index}"
|
||||
params << tipo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:nodos]}
|
||||
#{where_clause}
|
||||
ORDER BY nombre
|
||||
LIMIT $#{param_index}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# Busca nodo por nombre
|
||||
#
|
||||
# @param nombre [String] Nombre del nodo
|
||||
# @return [Hash, nil] Nodo encontrado o nil
|
||||
def find_nodo_by_nombre(nombre)
|
||||
query = <<~SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:nodos]}
|
||||
WHERE nombre = $1
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [nombre])
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA ENTRADAS (I-F-D-E)
|
||||
|
||||
# Crea una nueva entrada cronológica
|
||||
#
|
||||
# @param inicio [String, Time] Hora de inicio (formato HH:MM o Time)
|
||||
# @param descripcion [String] Descripción de la actividad
|
||||
# @param fin [String, Time, nil] Hora de fin (opcional)
|
||||
# @param estado [String] Estado (⏳, ✅, ❌, ⚠️, ⏸️)
|
||||
# @param modo [String] Modo (P=Presencial, R=Remoto)
|
||||
# @param es_ia [Boolean] Si fue generado por IA
|
||||
# @param tema_id [Integer, nil] ID del tema relacionado
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @return [Hash] Entrada creada con ID
|
||||
def create_entrada(inicio:, descripcion:, fin: nil, estado: nil, modo: nil, es_ia: false,
|
||||
tema_id: nil, bitacora_id: nil, nodo_id: nil)
|
||||
|
||||
# Validar formato de tiempo
|
||||
inicio_time = parse_time(inicio)
|
||||
fin_time = parse_time(fin) if fin
|
||||
|
||||
# Inferir Estado
|
||||
if estado.nil? || estado.strip.empty?
|
||||
estado = fin_time ? '✅' : '⏳'
|
||||
end
|
||||
|
||||
# Inferir Modo
|
||||
if modo.nil? || modo.strip.empty?
|
||||
if bitacora_id
|
||||
res = @connection.exec_params("SELECT modo FROM #{SCHEMA}.#{TABLES[:entradas]} WHERE bitacora_id = $1 ORDER BY id DESC LIMIT 1", [bitacora_id])
|
||||
modo = res.ntuples > 0 ? res[0]['modo'] : 'P'
|
||||
else
|
||||
modo = 'P'
|
||||
end
|
||||
end
|
||||
|
||||
# Validar estado
|
||||
unless ESTADOS_VALIDOS.include?(estado)
|
||||
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
# Validar modo
|
||||
unless MODOS_VALIDOS.include?(modo)
|
||||
raise ArgumentError, "Modo inválido. Válidos: #{MODOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:entradas]} (
|
||||
inicio, fin, descripcion, estado, modo, es_ia,
|
||||
tema_id, bitacora_id, nodo_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, inicio, fin, descripcion, estado, modo, es_ia,
|
||||
tema_id, bitacora_id, nodo_id, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(
|
||||
query,
|
||||
[inicio_time, fin_time, descripcion, estado, modo, es_ia,
|
||||
tema_id, bitacora_id, nodo_id]
|
||||
)
|
||||
result.first
|
||||
end
|
||||
|
||||
# Actualiza una entrada existente
|
||||
def update_entrada(id, params)
|
||||
# Auto-inferir estado si se está mandando una hora de fin y no se fuerza un estado explícito
|
||||
if params.key?(:fin) && !params.key?(:estado)
|
||||
if params[:fin].nil? || params[:fin].to_s.strip.empty?
|
||||
params[:estado] = '⏳'
|
||||
else
|
||||
params[:estado] = '✅'
|
||||
end
|
||||
end
|
||||
|
||||
set_clauses = []
|
||||
values = []
|
||||
i = 1
|
||||
|
||||
params.each do |k, v|
|
||||
if [:fin, :descripcion, :estado, :modo, :es_ia].include?(k)
|
||||
val = (k == :fin) ? parse_time(v) : v
|
||||
set_clauses << "#{k} = $#{i}"
|
||||
values << val
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
|
||||
return nil if set_clauses.empty?
|
||||
|
||||
query = <<~SQL
|
||||
UPDATE #{SCHEMA}.#{TABLES[:entradas]}
|
||||
SET #{set_clauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $#{i}
|
||||
RETURNING *
|
||||
SQL
|
||||
|
||||
values << id
|
||||
result = @connection.exec_params(query, values)
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# Lista entradas con filtros avanzados
|
||||
#
|
||||
# @param bitacora_id [Integer, nil] Filtrar por bitácora
|
||||
# @param nodo_id [Integer, nil] Filtrar por nodo
|
||||
# @param tema_id [Integer, nil] Filtrar por tema
|
||||
# @param estado [String, nil] Filtrar por estado
|
||||
# @param desde [Time, String, nil] Fecha/hora desde
|
||||
# @param hasta [Time, String, nil] Fecha/hora hasta
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @return [Array<Hash>] Lista de entradas
|
||||
def list_entradas(bitacora_id: nil, nodo_id: nil, tema_id: nil, estado: nil,
|
||||
desde: nil, hasta: nil, buscar: nil, limit: 100, offset: 0)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if bitacora_id
|
||||
conditions << "e.bitacora_id = $#{param_index}"
|
||||
params << bitacora_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
conditions << "e.nodo_id = $#{param_index}"
|
||||
params << nodo_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tema_id
|
||||
conditions << "e.tema_id = $#{param_index}"
|
||||
params << tema_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if estado
|
||||
conditions << "e.estado = $#{param_index}"
|
||||
params << estado
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if desde
|
||||
conditions << "b.fecha >= $#{param_index}"
|
||||
params << Date.parse(desde.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if hasta
|
||||
conditions << "b.fecha <= $#{param_index}"
|
||||
params << Date.parse(hasta.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if buscar
|
||||
conditions << "e.descripcion ILIKE $#{param_index}"
|
||||
params << "%#{buscar}%"
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
|
||||
b.fecha as bitacora_fecha,
|
||||
n.nombre as nodo_nombre,
|
||||
t.titulo as tema_titulo
|
||||
FROM #{SCHEMA}.#{TABLES[:entradas]} e
|
||||
LEFT JOIN #{SCHEMA}.#{TABLES[:bitacoras]} b ON e.bitacora_id = b.id
|
||||
LEFT JOIN #{SCHEMA}.#{TABLES[:nodos]} n ON e.nodo_id = n.id
|
||||
LEFT JOIN #{SCHEMA}.#{TABLES[:temas]} t ON e.tema_id = t.id
|
||||
#{where_clause}
|
||||
ORDER BY COALESCE(b.fecha, CURRENT_DATE) DESC, e.inicio DESC
|
||||
LIMIT $#{param_index} OFFSET $#{param_index + 1}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
params << offset
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA TEMAS
|
||||
|
||||
# Crea un nuevo tema (agrupador de entradas)
|
||||
#
|
||||
# @param id_tema [String] Identificador único del tema
|
||||
# @param titulo [String] Título del tema
|
||||
# @param estado [String] Estado actual
|
||||
# @param descripcion [String, nil] Descripción opcional
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora relacionada
|
||||
# @param nodo_id [Integer, nil] ID del nodo relacionado
|
||||
# @return [Hash] Tema creado con ID
|
||||
def create_tema(id_tema:, titulo:, estado: '⏳', descripcion: nil,
|
||||
bitacora_id: nil, nodo_id: nil)
|
||||
|
||||
unless ESTADOS_VALIDOS.include?(estado)
|
||||
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:temas]} (
|
||||
id_tema, titulo, estado, descripcion, bitacora_id, nodo_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, id_tema, titulo, estado, descripcion,
|
||||
bitacora_id, nodo_id, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(
|
||||
query,
|
||||
[id_tema, titulo, estado, descripcion, bitacora_id, nodo_id]
|
||||
)
|
||||
result.first
|
||||
end
|
||||
|
||||
# Lista temas con filtros
|
||||
#
|
||||
# @param bitacora_id [Integer, nil] Filtrar por bitácora
|
||||
# @param nodo_id [Integer, nil] Filtrar por nodo
|
||||
# @param estado [String, nil] Filtrar por estado
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @return [Array<Hash>] Lista de temas
|
||||
def list_temas(bitacora_id: nil, nodo_id: nil, estado: nil, limit: 100)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if bitacora_id
|
||||
conditions << "bitacora_id = $#{param_index}"
|
||||
params << bitacora_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
conditions << "nodo_id = $#{param_index}"
|
||||
params << nodo_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if estado
|
||||
conditions << "estado = $#{param_index}"
|
||||
params << estado
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, id_tema, titulo, estado, descripcion,
|
||||
bitacora_id, nodo_id, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:temas]}
|
||||
#{where_clause}
|
||||
ORDER BY id_tema
|
||||
LIMIT $#{param_index}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# MÉTODOS DE UTILIDAD
|
||||
|
||||
# Obtiene estadísticas del sistema
|
||||
#
|
||||
# @return [Hash] Estadísticas de todas las tablas
|
||||
def statistics
|
||||
return {} unless connection_test?
|
||||
|
||||
stats = {}
|
||||
|
||||
TABLES.each_key do |table|
|
||||
query = "SELECT COUNT(*) as total FROM #{SCHEMA}.#{TABLES[table]}"
|
||||
result = @connection.exec(query)
|
||||
stats[table] = result.first['total'].to_i
|
||||
end
|
||||
|
||||
# Estadísticas adicionales
|
||||
query = <<~SQL
|
||||
SELECT
|
||||
COUNT(DISTINCT nodo_id) as nodos_con_entradas,
|
||||
COUNT(DISTINCT DATE(created_at)) as dias_con_actividad,
|
||||
COUNT(CASE WHEN es_ia THEN 1 END) as entradas_ia,
|
||||
COUNT(CASE WHEN estado = '✅' THEN 1 END) as entradas_completadas
|
||||
FROM #{SCHEMA}.#{TABLES[:entradas]}
|
||||
SQL
|
||||
|
||||
result = @connection.exec(query)
|
||||
extra_stats = result.first
|
||||
|
||||
stats.merge(
|
||||
nodos_con_entradas: extra_stats['nodos_con_entradas'].to_i,
|
||||
dias_con_actividad: extra_stats['dias_con_actividad'].to_i,
|
||||
entradas_ia: extra_stats['entradas_ia'].to_i,
|
||||
entradas_completadas: extra_stats['entradas_completadas'].to_i
|
||||
)
|
||||
end
|
||||
|
||||
# Ejecuta una consulta SQL personalizada
|
||||
#
|
||||
# @param sql [String] Consulta SQL
|
||||
# @param params [Array] Parámetros para la consulta preparada
|
||||
# @return [PG::Result] Resultado de la consulta
|
||||
def execute(sql, params = [])
|
||||
@connection.exec_params(sql, params)
|
||||
end
|
||||
|
||||
# Verifica si existe una bitácora para hoy
|
||||
#
|
||||
# @return [Boolean] true si existe bitácora para hoy
|
||||
def bitacora_hoy_existe?
|
||||
fecha_hoy = Date.today.to_s
|
||||
query = "SELECT 1 FROM #{SCHEMA}.#{TABLES[:bitacoras]} WHERE fecha = $1 LIMIT 1"
|
||||
result = @connection.exec_params(query, [fecha_hoy])
|
||||
!result.ntuples.zero?
|
||||
end
|
||||
|
||||
# Crea bitácora para hoy si no existe
|
||||
#
|
||||
# @param resumen [String, nil] Resumen opcional
|
||||
# @return [Hash, nil] Bitácora creada o nil si ya existía
|
||||
def crear_bitacora_hoy(resumen: nil)
|
||||
return nil if bitacora_hoy_existe?
|
||||
|
||||
create_bitacora(fecha: Date.today, resumen: resumen)
|
||||
end
|
||||
|
||||
|
||||
# ==========================================
|
||||
# PROYECTOS, FASES E HITOS (Planes Sistemáticos)
|
||||
# ==========================================
|
||||
|
||||
def find_proyecto_by_codigo(codigo)
|
||||
q = "SELECT * FROM bitacoras.proyectos WHERE codigo = $1"
|
||||
res = execute(q, [codigo.upcase])
|
||||
return nil if res.ntuples.zero?
|
||||
res.first.to_h
|
||||
end
|
||||
|
||||
def list_hitos(proyecto_codigo)
|
||||
proyecto = find_proyecto_by_codigo(proyecto_codigo)
|
||||
raise StandardError, "Proyecto no encontrado: #{proyecto_codigo}" unless proyecto
|
||||
|
||||
q = <<~SQL
|
||||
SELECT f.numero as fase_num, f.nombre as fase_nombre,
|
||||
h.id, h.id_hito, h.titulo, h.estado, h.horas_presencial, h.horas_remoto
|
||||
FROM bitacoras.fases f
|
||||
JOIN bitacoras.hitos h ON h.fase_id = f.id
|
||||
WHERE f.proyecto_id = $1
|
||||
ORDER BY f.numero, h.id_hito
|
||||
SQL
|
||||
res = execute(q, [proyecto['id']])
|
||||
res.map(&:to_h)
|
||||
end
|
||||
|
||||
def rename_hito_id(proyecto_codigo, id_actual, id_nuevo)
|
||||
q_busqueda = <<~SQL
|
||||
SELECT h.id, h.titulo
|
||||
FROM bitacoras.hitos h
|
||||
JOIN bitacoras.fases f ON h.fase_id = f.id
|
||||
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
|
||||
WHERE p.codigo = $1 AND h.id_hito = $2
|
||||
SQL
|
||||
|
||||
res = execute(q_busqueda, [proyecto_codigo.upcase, id_actual])
|
||||
raise StandardError, "Hito #{id_actual} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
|
||||
|
||||
hito = res.first
|
||||
|
||||
q_update = "UPDATE bitacoras.hitos SET id_hito = $1 WHERE id = $2 RETURNING titulo"
|
||||
res_update = execute(q_update, [id_nuevo, hito['id']])
|
||||
|
||||
res_update.first
|
||||
end
|
||||
|
||||
def update_hito(proyecto_codigo, id_hito, estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil)
|
||||
q_busqueda = <<~SQL
|
||||
SELECT h.id, h.horas_presencial, h.horas_remoto, h.estado, h.descripcion
|
||||
FROM bitacoras.hitos h
|
||||
JOIN bitacoras.fases f ON h.fase_id = f.id
|
||||
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
|
||||
WHERE p.codigo = $1 AND h.id_hito = $2
|
||||
SQL
|
||||
|
||||
res = execute(q_busqueda, [proyecto_codigo.upcase, id_hito])
|
||||
raise StandardError, "Hito #{id_hito} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
|
||||
|
||||
hito = res.first
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
param_count = 1
|
||||
|
||||
if estado
|
||||
updates << "estado = $#{param_count}"
|
||||
params << estado
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
unless horas_presencial.nil?
|
||||
updates << "horas_presencial = $#{param_count}"
|
||||
params << horas_presencial
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
unless horas_remoto.nil?
|
||||
updates << "horas_remoto = $#{param_count}"
|
||||
params << horas_remoto
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
if descripcion
|
||||
updates << "descripcion = $#{param_count}"
|
||||
params << descripcion
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
return hito if updates.empty?
|
||||
|
||||
updates << "updated_at = NOW()"
|
||||
params << hito['id']
|
||||
|
||||
query = "UPDATE bitacoras.hitos SET #{updates.join(', ')} WHERE id = $#{param_count} RETURNING *"
|
||||
|
||||
resultado = execute(query, params)
|
||||
resultado.first.to_h
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Ruta por defecto al archivo de configuración
|
||||
def default_config_path
|
||||
File.expand_path('../../config/database.yml', __dir__)
|
||||
end
|
||||
|
||||
# Logger por defecto (ADN.logger si está disponible)
|
||||
def default_logger
|
||||
# Intentar usar ADN.logger si el módulo ADN está cargado
|
||||
if defined?(ADN) && ADN.respond_to?(:logger)
|
||||
ADN.logger
|
||||
else
|
||||
# Logger simple como fallback
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
def warn(msg); puts "[WARN] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
end
|
||||
|
||||
# Parsea un tiempo a formato HH:MM:SS
|
||||
#
|
||||
# @param time [String, Time, nil] Tiempo a parsear
|
||||
# @return [String, nil] Tiempo en formato HH:MM:SS o nil
|
||||
def parse_time(time)
|
||||
return nil if time.nil?
|
||||
|
||||
case time
|
||||
when Time
|
||||
time.strftime('%H:%M:%S')
|
||||
when String
|
||||
# Intentar parsear varios formatos
|
||||
if time.match?(/^\d{1,2}:\d{2}$/) # HH:MM
|
||||
"#{time}:00"
|
||||
elsif time.match?(/^\d{1,2}:\d{2}:\d{2}$/) # HH:MM:SS
|
||||
time
|
||||
else
|
||||
raise ArgumentError, "Formato de tiempo inválido: #{time}. Use HH:MM o HH:MM:SS"
|
||||
end
|
||||
else
|
||||
raise ArgumentError, "Tiempo debe ser String o Time, recibido: #{time.class}"
|
||||
end
|
||||
end
|
||||
|
||||
# Convierte un resultado PG a Hash
|
||||
def result_to_hash(result)
|
||||
return {} if result.ntuples.zero?
|
||||
result.first.to_h
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,772 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/buscador_bd.rb — Sistema de búsqueda y filtrado para dtic-BITACORAs
|
||||
# ================================================================================
|
||||
# Esta clase proporciona capacidades avanzadas de búsqueda y filtrado para la
|
||||
# base de datos dtic-BITACORAs. Implementa:
|
||||
# • Búsqueda full-text con PostgreSQL tsvector/tsquery
|
||||
# • Filtrado por múltiples criterios (estado, modo, fecha, etc.)
|
||||
# • Búsqueda parcial y exacta en campos de texto
|
||||
# • Paginación y ordenamiento de resultados
|
||||
# • Exportación en múltiples formatos (texto, JSON, CSV)
|
||||
#
|
||||
# Uso básico:
|
||||
# buscador = BitacorasDB::BuscadorBD.new
|
||||
# resultados = buscador.buscar_entradas(texto: "reunión", estado: "✅", limite: 10)
|
||||
# estadisticas = buscador.estadisticas_busquedas(periodo: "7dias")
|
||||
#
|
||||
# Integración con BitacoraDB existente:
|
||||
# BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# buscador = BitacorasDB::BuscadorBD.new(db.connection)
|
||||
# resultados = buscador.buscar_entradas(texto: "dashboard")
|
||||
# end
|
||||
#
|
||||
# Comandos CLI asociados:
|
||||
# • adn db buscar:entradas [TEXTO] [OPCIONES]
|
||||
# • adn db filtrar:entradas [--estado ESTADO] [--modo MODO] [--fecha DESDE-HASTA]
|
||||
# • adn db buscar:nodos [TEXTO] [--tipo TIPO]
|
||||
# • adn db buscar:temas [TEXTO] [--nodo NODO]
|
||||
# • adn db estadisticas:busquedas [--periodo PERIODO]
|
||||
|
||||
require 'pg'
|
||||
require 'json'
|
||||
require 'csv'
|
||||
require 'date'
|
||||
|
||||
module BitacorasDB
|
||||
class BuscadorBD
|
||||
# Configuración por defecto
|
||||
DEFAULT_LIMIT = 100
|
||||
DEFAULT_OFFSET = 0
|
||||
DEFAULT_ORDER = 'created_at DESC'
|
||||
|
||||
# Campos de búsqueda full-text por tabla
|
||||
FULL_TEXT_FIELDS = {
|
||||
entradas: ['descripcion'],
|
||||
temas: ['descripcion'],
|
||||
nodos: ['descripcion'],
|
||||
bitacoras: ['resumen']
|
||||
}.freeze
|
||||
|
||||
# Campos de filtrado por tabla
|
||||
FILTERABLE_FIELDS = {
|
||||
entradas: ['estado', 'modo', 'nodo_id', 'tema_id', 'bitacora_id', 'es_ia'],
|
||||
temas: ['estado', 'modo', 'nodo_id', 'bitacora_id'],
|
||||
nodos: ['tipo', 'activo'],
|
||||
bitacoras: [] # Filtrado principalmente por fecha
|
||||
}.freeze
|
||||
|
||||
# Rangos de fecha/hora permitidos
|
||||
DATE_RANGE_FIELDS = ['fecha', 'created_at', 'updated_at']
|
||||
TIME_RANGE_FIELDS = ['inicio', 'fin']
|
||||
|
||||
# Inicializa el buscador con una conexión PostgreSQL
|
||||
#
|
||||
# @param connection [PG::Connection] Conexión activa a PostgreSQL
|
||||
# @param logger [Object] Instancia de logger (opcional)
|
||||
def initialize(connection, logger = nil)
|
||||
@conn = connection
|
||||
@logger = logger
|
||||
@query_count = 0
|
||||
@query_times = []
|
||||
end
|
||||
|
||||
# Búsqueda principal en entradas con múltiples criterios
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda full-text
|
||||
# @param estado [String, nil] Estado de la entrada (⏳, ✅, ❌, ⚠️, ⏸️)
|
||||
# @param modo [String, nil] Modo de trabajo (P, R)
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param tema_id [Integer, nil] ID del tema
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora
|
||||
# @param fecha_desde [Date, String, nil] Fecha inicial
|
||||
# @param fecha_hasta [Date, String, nil] Fecha final
|
||||
# @param inicio_desde [Time, String, nil] Hora inicial desde
|
||||
# @param inicio_hasta [Time, String, nil] Hora inicial hasta
|
||||
# @param es_ia [Boolean, nil] Entradas generadas por IA
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento para paginación
|
||||
# @param orden [String] Campo y dirección de ordenamiento
|
||||
# @param formato [Symbol] Formato de salida (:hash, :json, :csv)
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados en formato especificado
|
||||
def buscar_entradas(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
modo: nil,
|
||||
nodo_id: nil,
|
||||
tema_id: nil,
|
||||
bitacora_id: nil,
|
||||
fecha_desde: nil,
|
||||
fecha_hasta: nil,
|
||||
inicio_desde: nil,
|
||||
inicio_hasta: nil,
|
||||
es_ia: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: DEFAULT_ORDER,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
|
||||
# Búsqueda full-text
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << build_full_text_condition('entradas', texto, parametros)
|
||||
end
|
||||
|
||||
# Filtros simples
|
||||
if estado
|
||||
condiciones << add_simple_filter('estado', estado, parametros)
|
||||
end
|
||||
|
||||
if modo
|
||||
condiciones << add_simple_filter('modo', modo, parametros)
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
condiciones << add_simple_filter('nodo_id', nodo_id, parametros)
|
||||
end
|
||||
|
||||
if tema_id
|
||||
condiciones << add_simple_filter('tema_id', tema_id, parametros)
|
||||
end
|
||||
|
||||
if bitacora_id
|
||||
condiciones << add_simple_filter('bitacora_id', bitacora_id, parametros)
|
||||
end
|
||||
|
||||
if !es_ia.nil?
|
||||
condiciones << add_simple_filter('es_ia', es_ia, parametros)
|
||||
end
|
||||
|
||||
# Filtros de rango de fecha
|
||||
if fecha_desde || fecha_hasta
|
||||
condiciones << build_date_range_condition('created_at', fecha_desde, fecha_hasta, parametros)
|
||||
end
|
||||
|
||||
# Filtros de rango de hora
|
||||
if inicio_desde || inicio_hasta
|
||||
condiciones << build_time_range_condition('inicio', inicio_desde, inicio_hasta, parametros)
|
||||
end
|
||||
|
||||
# Construir consulta SQL
|
||||
sql = build_entradas_query(condiciones, orden, limite, offset, parametros.size)
|
||||
|
||||
# Ejecutar consulta
|
||||
resultados = execute_query(sql, parametros)
|
||||
|
||||
# Formatear resultados
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda en nodos con filtros
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda
|
||||
# @param tipo [String, nil] Tipo de nodo (servidor, vm, pc, etc.)
|
||||
# @param activo [Boolean, nil] Estado activo/inactivo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param orden [String] Ordenamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_nodos(
|
||||
texto: nil,
|
||||
tipo: nil,
|
||||
activo: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: 'nombre ASC',
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda en nombre y descripción
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "(nombre ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
condiciones << add_simple_filter('tipo', tipo, parametros, contador) if tipo
|
||||
contador += 1 if tipo
|
||||
|
||||
condiciones << add_simple_filter('activo', activo, parametros, contador) if !activo.nil?
|
||||
contador += 1 if !activo.nil?
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM bitacoras.nodos
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = execute_query(sql, parametros)
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda en temas con filtros
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda
|
||||
# @param estado [String, nil] Estado del tema
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param orden [String] Ordenamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_temas(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
nodo_id: nil,
|
||||
bitacora_id: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: 'titulo ASC',
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda full-text en descripción
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << build_full_text_condition('temas', texto, parametros, contador)
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
condiciones << add_simple_filter('estado', estado, parametros, contador) if estado
|
||||
contador += 1 if estado
|
||||
|
||||
condiciones << add_simple_filter('nodo_id', nodo_id, parametros, contador) if nodo_id
|
||||
contador += 1 if nodo_id
|
||||
|
||||
condiciones << add_simple_filter('bitacora_id', bitacora_id, parametros, contador) if bitacora_id
|
||||
contador += 1 if bitacora_id
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, id_tema, titulo, estado, descripcion,
|
||||
nodo_id, bitacora_id, created_at, updated_at
|
||||
FROM bitacoras.temas
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = execute_query(sql, parametros)
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda en bitácoras
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en resumen
|
||||
# @param fecha_desde [Date, String, nil] Fecha inicial
|
||||
# @param fecha_hasta [Date, String, nil] Fecha final
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param orden [String] Ordenamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_bitacoras(
|
||||
texto: nil,
|
||||
fecha_desde: nil,
|
||||
fecha_hasta: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: 'fecha DESC',
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda en resumen
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "resumen ILIKE $#{contador}"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtro por rango de fecha
|
||||
if fecha_desde || fecha_hasta
|
||||
condiciones << build_date_range_condition('fecha', fecha_desde, fecha_hasta, parametros, contador)
|
||||
contador += (fecha_desde ? 1 : 0) + (fecha_hasta ? 1 : 0)
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM bitacoras.bitacoras
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = execute_query(sql, parametros)
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda avanzada con joins entre tablas
|
||||
#
|
||||
# @param texto [String] Texto para búsqueda en múltiples tablas
|
||||
# @param tipos [Array<Symbol>] Tipos de entidades a buscar (:entradas, :temas, :nodos, :bitacoras)
|
||||
# @param limite_por_tipo [Integer] Límite por tipo de entidad
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Hash] Resultados agrupados por tipo
|
||||
def buscar_avanzado(
|
||||
texto:,
|
||||
tipos: [:entradas, :temas, :nodos, :bitacoras],
|
||||
limite_por_tipo: 10,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
resultados = {}
|
||||
|
||||
tipos.each do |tipo|
|
||||
case tipo
|
||||
when :entradas
|
||||
resultados[:entradas] = buscar_entradas(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
when :temas
|
||||
resultados[:temas] = buscar_temas(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
when :nodos
|
||||
resultados[:nodos] = buscar_nodos(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
when :bitacoras
|
||||
resultados[:bitacoras] = buscar_bitacoras(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
end
|
||||
end
|
||||
|
||||
# Calcular estadísticas
|
||||
resultados[:estadisticas] = {
|
||||
total_resultados: resultados.values.sum { |r| r.is_a?(Array) ? r.size : 0 },
|
||||
tipos_buscados: tipos,
|
||||
texto_buscado: texto,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Estadísticas de uso del sistema de búsqueda
|
||||
#
|
||||
# @param periodo [String] Período para estadísticas (7dias, 30dias, hoy, semana, mes)
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Hash, String] Estadísticas
|
||||
def estadisticas_busquedas(periodo: '7dias', formato: :hash)
|
||||
fecha_desde = calculate_period_start(periodo)
|
||||
|
||||
# En un sistema real, aquí se consultaría una tabla de logs de búsquedas
|
||||
# Por ahora, devolvemos estadísticas simuladas basadas en el análisis del esquema
|
||||
|
||||
estadisticas = {
|
||||
periodo: periodo,
|
||||
fecha_desde: fecha_desde,
|
||||
fecha_hasta: Date.today.to_s,
|
||||
total_busquedas: @query_count,
|
||||
tiempo_promedio: @query_times.empty? ? 0 : @query_times.sum / @query_times.size,
|
||||
tipos_mas_buscados: ['entradas', 'temas', 'nodos'],
|
||||
campos_filtro_populares: ['estado', 'fecha', 'modo'],
|
||||
recomendaciones: [
|
||||
'Considerar agregar índice full-text en entradas.descripcion',
|
||||
'Implementar cache de consultas frecuentes',
|
||||
'Agregar logging de búsquedas para análisis posterior'
|
||||
]
|
||||
}
|
||||
|
||||
format_single_result(estadisticas, formato)
|
||||
end
|
||||
|
||||
# Crear índices recomendados para optimización
|
||||
#
|
||||
# @return [Hash] Resultado de la operación
|
||||
def crear_indices_recomendados
|
||||
indices = [
|
||||
# Índices full-text
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_descripcion_fts ON bitacoras.entradas USING gin(to_tsvector('spanish', descripcion))",
|
||||
"CREATE INDEX IF NOT EXISTS idx_temas_descripcion_fts ON bitacoras.temas USING gin(to_tsvector('spanish', descripcion))",
|
||||
|
||||
# Índices para filtrado frecuente
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_estado ON bitacoras.entradas(estado)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_modo ON bitacoras.entradas(modo)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_created_at ON bitacoras.entradas(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_bitacoras_fecha ON bitacoras.bitacoras(fecha)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_nodos_tipo ON bitacoras.nodos(tipo)",
|
||||
|
||||
# Índices para joins
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_nodo_id ON bitacoras.entradas(nodo_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_tema_id ON bitacoras.entradas(tema_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_bitacora_id ON bitacoras.entradas(bitacora_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_temas_nodo_id ON bitacoras.temas(nodo_id)"
|
||||
]
|
||||
|
||||
resultados = []
|
||||
indices.each do |sql|
|
||||
begin
|
||||
start_time = Time.now
|
||||
@conn.exec(sql)
|
||||
elapsed = Time.now - start_time
|
||||
resultados << { indice: sql, estado: 'creado', tiempo: elapsed.round(3) }
|
||||
@logger&.info("Índice creado: #{sql.split(' ON ').first}") if @logger
|
||||
rescue PG::Error => e
|
||||
resultados << { indice: sql, estado: 'error', error: e.message }
|
||||
@logger&.error("Error creando índice: #{e.message}") if @logger
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
total_indices: indices.size,
|
||||
creados: resultados.count { |r| r[:estado] == 'creado' },
|
||||
errores: resultados.count { |r| r[:estado] == 'error' },
|
||||
detalles: resultados,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
# Métricas de rendimiento del sistema de búsqueda
|
||||
#
|
||||
# @return [Hash] Métricas de rendimiento
|
||||
def metricas_rendimiento
|
||||
# Consultar estadísticas de PostgreSQL
|
||||
stats = {}
|
||||
|
||||
begin
|
||||
# Tamaño de tablas
|
||||
size_query = <<-SQL
|
||||
SELECT
|
||||
table_name,
|
||||
pg_size_pretty(pg_total_relation_size('bitacoras.' || table_name)) as tamaño_total,
|
||||
pg_size_pretty(pg_relation_size('bitacoras.' || table_name)) as tamaño_tabla,
|
||||
pg_size_pretty(pg_indexes_size('bitacoras.' || table_name)) as tamaño_indices,
|
||||
n_live_tup as registros
|
||||
FROM information_schema.tables t
|
||||
JOIN pg_stat_user_tables s ON t.table_name = s.relname
|
||||
WHERE t.table_schema = 'bitacoras'
|
||||
ORDER BY pg_total_relation_size('bitacoras.' || table_name) DESC
|
||||
SQL
|
||||
|
||||
stats[:tamanos_tablas] = @conn.exec(size_query).to_a
|
||||
|
||||
# Índices más usados
|
||||
index_query = <<-SQL
|
||||
SELECT
|
||||
indexrelname as nombre_indice,
|
||||
idx_scan as escaneos,
|
||||
idx_tup_read as tuplas_leidas,
|
||||
idx_tup_fetch as tuplas_recuperadas
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE schemaname = 'bitacoras'
|
||||
ORDER BY idx_scan DESC
|
||||
LIMIT 10
|
||||
SQL
|
||||
|
||||
stats[:indices_uso] = @conn.exec(index_query).to_a
|
||||
|
||||
# Consultas lentas (si está habilitado pg_stat_statements)
|
||||
begin
|
||||
slow_query = <<-SQL
|
||||
SELECT
|
||||
query,
|
||||
calls,
|
||||
total_time,
|
||||
mean_time,
|
||||
rows
|
||||
FROM pg_stat_statements
|
||||
WHERE query LIKE '%bitacoras.%'
|
||||
ORDER BY mean_time DESC
|
||||
LIMIT 5
|
||||
SQL
|
||||
stats[:consultas_lentas] = @conn.exec(slow_query).to_a
|
||||
rescue PG::Error
|
||||
stats[:consultas_lentas] = []
|
||||
end
|
||||
|
||||
rescue PG::Error => e
|
||||
stats[:error] = e.message
|
||||
end
|
||||
|
||||
# Métricas internas
|
||||
stats[:metricas_internas] = {
|
||||
total_consultas: @query_count,
|
||||
tiempo_promedio: @query_times.empty? ? 0 : @query_times.sum / @query_times.size,
|
||||
consultas_mas_lenta: @query_times.max,
|
||||
consultas_mas_rapida: @query_times.min
|
||||
}
|
||||
|
||||
stats
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Construir condición de búsqueda full-text
|
||||
def build_full_text_condition(table, texto, parametros)
|
||||
campos = FULL_TEXT_FIELDS[table.to_sym] || []
|
||||
return "1=0" if campos.empty?
|
||||
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
|
||||
prefijo = table == 'entradas' ? 'e.' : ''
|
||||
|
||||
condiciones = campos.map do |campo|
|
||||
"to_tsvector('spanish', #{prefijo}#{campo}) @@ plainto_tsquery('spanish', $#{parametros.size + 1})"
|
||||
end
|
||||
|
||||
parametros << texto
|
||||
"(#{condiciones.join(' OR ')})"
|
||||
end
|
||||
|
||||
# Agregar filtro simple
|
||||
def add_simple_filter(campo, valor, parametros)
|
||||
parametros << valor
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
|
||||
prefijo = campo.include?('.') ? '' : (campo == 'nodo_id' || campo == 'tema_id' || campo == 'bitacora_id' || campo == 'estado' || campo == 'modo' || campo == 'es_ia' ? 'e.' : '')
|
||||
"#{prefijo}#{campo} = $#{parametros.size}"
|
||||
end
|
||||
|
||||
# Construir condición de rango de fecha
|
||||
def build_date_range_condition(campo, desde, hasta, parametros)
|
||||
condiciones = []
|
||||
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.' para created_at
|
||||
prefijo = campo == 'created_at' ? 'e.' : ''
|
||||
|
||||
if desde
|
||||
parametros << desde
|
||||
condiciones << "#{prefijo}#{campo} >= $#{parametros.size}"
|
||||
end
|
||||
|
||||
if hasta
|
||||
parametros << hasta
|
||||
condiciones << "#{prefijo}#{campo} <= $#{parametros.size}"
|
||||
end
|
||||
|
||||
condiciones.any? ? "(#{condiciones.join(' AND ')})" : "1=1"
|
||||
end
|
||||
|
||||
# Construir condición de rango de hora
|
||||
def build_time_range_condition(campo, desde, hasta, parametros)
|
||||
condiciones = []
|
||||
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
|
||||
prefijo = campo == 'inicio' ? 'e.' : ''
|
||||
|
||||
if desde
|
||||
parametros << desde
|
||||
condiciones << "#{prefijo}#{campo} >= $#{parametros.size}"
|
||||
end
|
||||
|
||||
if hasta
|
||||
parametros << hasta
|
||||
condiciones << "#{prefijo}#{campo} <= $#{parametros.size}"
|
||||
end
|
||||
|
||||
condiciones.any? ? "(#{condiciones.join(' AND ')})" : "1=1"
|
||||
end
|
||||
|
||||
# Construir consulta SQL para entradas
|
||||
def build_entradas_query(condiciones, orden, limite, offset, num_parametros)
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
|
||||
<<-SQL
|
||||
SELECT
|
||||
e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
|
||||
n.nombre as nodo_nombre, n.tipo as nodo_tipo,
|
||||
t.titulo as tema_titulo, t.id_tema as tema_codigo,
|
||||
b.fecha as bitacora_fecha
|
||||
FROM bitacoras.entradas e
|
||||
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
|
||||
LEFT JOIN bitacoras.temas t ON e.tema_id = t.id
|
||||
LEFT JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{num_parametros + 1} OFFSET $#{num_parametros + 2}
|
||||
SQL
|
||||
end
|
||||
|
||||
# Ejecutar consulta con medición de tiempo
|
||||
def execute_query(sql, parametros)
|
||||
start_time = Time.now
|
||||
|
||||
begin
|
||||
resultado = @conn.exec_params(sql, parametros)
|
||||
elapsed = Time.now - start_time
|
||||
|
||||
@query_count += 1
|
||||
@query_times << elapsed
|
||||
|
||||
@logger&.debug("Consulta ejecutada en #{elapsed.round(3)}s: #{sql.split('LIMIT').first}...") if @logger
|
||||
|
||||
resultado
|
||||
rescue PG::Error => e
|
||||
@logger&.error("Error en consulta SQL: #{e.message}\nSQL: #{sql}") if @logger
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# Formatear resultados según formato solicitado
|
||||
def format_results(resultados, formato)
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :csv
|
||||
return "" if resultados.ntuples == 0
|
||||
|
||||
CSV.generate do |csv|
|
||||
# Encabezados
|
||||
csv << resultados.fields
|
||||
|
||||
# Datos
|
||||
resultados.each do |row|
|
||||
csv << row.values
|
||||
end
|
||||
end
|
||||
when :texto
|
||||
format_results_as_text(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Formatear resultados como texto legible
|
||||
def format_results_as_text(resultados)
|
||||
return "No se encontraron resultados" if resultados.ntuples == 0
|
||||
|
||||
output = []
|
||||
output << "📊 Resultados encontrados: #{resultados.ntuples}"
|
||||
output << "=" * 60
|
||||
|
||||
resultados.each_with_index do |row, index|
|
||||
output << "\n#{index + 1}. #{row['descripcion'] || row['nombre'] || row['titulo'] || 'Sin título'}"
|
||||
|
||||
row.each do |key, value|
|
||||
next if value.nil? || value.to_s.empty?
|
||||
next if key == 'descripcion' || key == 'nombre' || key == 'titulo'
|
||||
|
||||
# Formatear según el tipo de campo
|
||||
formatted_key = key.gsub('_', ' ').capitalize
|
||||
formatted_value = case key
|
||||
when 'created_at', 'updated_at', 'fecha'
|
||||
DateTime.parse(value.to_s).strftime('%Y-%m-%d %H:%M')
|
||||
when 'inicio', 'fin'
|
||||
value.to_s[0..4] # HH:MM
|
||||
when 'es_ia'
|
||||
value == 't' ? 'Sí' : 'No'
|
||||
when 'estado'
|
||||
"#{value} #{estado_emoji(value)}"
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
|
||||
output << " • #{formatted_key}: #{formatted_value}"
|
||||
end
|
||||
end
|
||||
|
||||
output.join("\n")
|
||||
end
|
||||
|
||||
# Formatear resultado único
|
||||
def format_single_result(resultado, formato)
|
||||
case formato
|
||||
when :hash
|
||||
resultado
|
||||
when :json
|
||||
JSON.pretty_generate(resultado)
|
||||
when :texto
|
||||
format_single_result_as_text(resultado)
|
||||
else
|
||||
resultado
|
||||
end
|
||||
end
|
||||
|
||||
# Formatear resultado único como texto
|
||||
def format_single_result_as_text(resultado)
|
||||
output = []
|
||||
output << "📈 #{resultado[:titulo] || 'Estadísticas'}"
|
||||
output << "=" * 50
|
||||
|
||||
resultado.each do |key, value|
|
||||
next if key == :titulo
|
||||
|
||||
formatted_key = key.to_s.gsub('_', ' ').capitalize
|
||||
|
||||
if value.is_a?(Hash)
|
||||
output << "\n#{formatted_key}:"
|
||||
value.each do |subkey, subvalue|
|
||||
output << " • #{subkey}: #{subvalue}"
|
||||
end
|
||||
elsif value.is_a?(Array)
|
||||
output << "\n#{formatted_key} (#{value.size}):"
|
||||
value.first(5).each_with_index do |item, idx|
|
||||
output << " #{idx + 1}. #{item}"
|
||||
end
|
||||
output << " ..." if value.size > 5
|
||||
else
|
||||
output << "• #{formatted_key}: #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
output.join("\n")
|
||||
end
|
||||
|
||||
# Calcular fecha de inicio según período
|
||||
def calculate_period_start(periodo)
|
||||
case periodo.to_s.downcase
|
||||
when 'hoy'
|
||||
Date.today
|
||||
when 'ayer'
|
||||
Date.today - 1
|
||||
when '7dias', 'semana'
|
||||
Date.today - 7
|
||||
when '30dias', 'mes'
|
||||
Date.today - 30
|
||||
when '90dias', 'trimestre'
|
||||
Date.today - 90
|
||||
when '365dias', 'año'
|
||||
Date.today - 365
|
||||
else
|
||||
Date.today - 7 # Por defecto 7 días
|
||||
end
|
||||
end
|
||||
|
||||
# Emoji para estado
|
||||
def estado_emoji(estado)
|
||||
case estado
|
||||
when '⏳' then '⏳'
|
||||
when '✅' then '✅'
|
||||
when '❌' then '❌'
|
||||
when '⚠️' then '⚠️'
|
||||
when '⏸️' then '⏸️'
|
||||
else estado
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,586 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/buscador_bd_simple.rb — Versión simplificada del sistema de búsqueda
|
||||
# ================================================================================
|
||||
# Versión simplificada y funcional del sistema de búsqueda para dtic-BITACORAs
|
||||
# Esta versión evita problemas complejos de parámetros y se enfoca en funcionalidad básica
|
||||
|
||||
require 'pg'
|
||||
require 'json'
|
||||
require 'csv'
|
||||
require 'date'
|
||||
|
||||
module BitacorasDB
|
||||
class BuscadorBDSimple
|
||||
# Configuración por defecto
|
||||
DEFAULT_LIMIT = 50
|
||||
DEFAULT_OFFSET = 0
|
||||
|
||||
# Inicializa el buscador con una conexión PostgreSQL
|
||||
#
|
||||
# @param connection [PG::Connection] Conexión activa a PostgreSQL
|
||||
# @param logger [Object] Instancia de logger (opcional)
|
||||
def initialize(connection, logger = nil)
|
||||
@conn = connection
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
# Búsqueda simple en entradas
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda (LIKE)
|
||||
# @param estado [String, nil] Estado de la entrada (⏳, ✅, ❌, ⚠️, ⏸️)
|
||||
# @param modo [String, nil] Modo de trabajo (P, R)
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento para paginación
|
||||
# @param formato [Symbol] Formato de salida (:hash, :json, :texto)
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados en formato especificado
|
||||
def buscar_entradas_simple(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
modo: nil,
|
||||
nodo_id: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "e.descripcion ILIKE $#{contador}"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
if estado
|
||||
condiciones << "e.estado = $#{contador}"
|
||||
parametros << estado
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if modo
|
||||
condiciones << "e.modo = $#{contador}"
|
||||
parametros << modo
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
condiciones << "e.nodo_id = $#{contador}"
|
||||
parametros << nodo_id
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta SQL
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
|
||||
sql = <<-SQL
|
||||
SELECT
|
||||
e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
|
||||
n.nombre as nodo_nombre, n.tipo as nodo_tipo,
|
||||
t.titulo as tema_titulo,
|
||||
b.fecha as bitacora_fecha
|
||||
FROM bitacoras.entradas e
|
||||
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
|
||||
LEFT JOIN bitacoras.temas t ON e.tema_id = t.id
|
||||
LEFT JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
|
||||
#{where_clause}
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
# Agregar límite y offset a parámetros
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
# Ejecutar consulta
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
# Formatear resultados
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda simple en nodos
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en nombre o descripción
|
||||
# @param tipo [String, nil] Tipo de nodo (servidor, vm, pc, etc.)
|
||||
# @param activo [Boolean, nil] Estado activo/inactivo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_nodos_simple(
|
||||
texto: nil,
|
||||
tipo: nil,
|
||||
activo: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "(nombre ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
if tipo
|
||||
condiciones << "tipo = $#{contador}"
|
||||
parametros << tipo
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if !activo.nil?
|
||||
condiciones << "activo = $#{contador}"
|
||||
parametros << activo
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM bitacoras.nodos
|
||||
#{where_clause}
|
||||
ORDER BY nombre ASC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda simple en temas
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en título o descripción
|
||||
# @param estado [String, nil] Estado del tema
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_temas_simple(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
nodo_id: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "(titulo ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
if estado
|
||||
condiciones << "estado = $#{contador}"
|
||||
parametros << estado
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
condiciones << "nodo_id = $#{contador}"
|
||||
parametros << nodo_id
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, id_tema, titulo, estado, descripcion,
|
||||
nodo_id, bitacora_id, created_at, updated_at
|
||||
FROM bitacoras.temas
|
||||
#{where_clause}
|
||||
ORDER BY titulo ASC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda simple en bitácoras
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en resumen
|
||||
# @param fecha_desde [Date, String, nil] Fecha inicial
|
||||
# @param fecha_hasta [Date, String, nil] Fecha final
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_bitacoras_simple(
|
||||
texto: nil,
|
||||
fecha_desde: nil,
|
||||
fecha_hasta: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "resumen ILIKE $#{contador}"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtro por fecha
|
||||
if fecha_desde
|
||||
condiciones << "fecha >= $#{contador}"
|
||||
parametros << fecha_desde
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if fecha_hasta
|
||||
condiciones << "fecha <= $#{contador}"
|
||||
parametros << fecha_hasta
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM bitacoras.bitacoras
|
||||
#{where_clause}
|
||||
ORDER BY fecha DESC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda avanzada simple (múltiples tablas)
|
||||
#
|
||||
# @param texto [String] Texto para búsqueda en múltiples tablas
|
||||
# @param limite_por_tipo [Integer] Límite por tipo de entidad
|
||||
#
|
||||
# @return [Hash] Resultados agrupados por tipo
|
||||
def buscar_avanzado_simple(texto:, limite_por_tipo: 5)
|
||||
resultados = {}
|
||||
|
||||
# Buscar en entradas
|
||||
begin
|
||||
resultados[:entradas] = buscar_entradas_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:entradas_error] = e.message
|
||||
end
|
||||
|
||||
# Buscar en nodos
|
||||
begin
|
||||
resultados[:nodos] = buscar_nodos_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:nodos_error] = e.message
|
||||
end
|
||||
|
||||
# Buscar en temas
|
||||
begin
|
||||
resultados[:temas] = buscar_temas_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:temas_error] = e.message
|
||||
end
|
||||
|
||||
# Buscar en bitácoras
|
||||
begin
|
||||
resultados[:bitacoras] = buscar_bitacoras_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:bitacoras_error] = e.message
|
||||
end
|
||||
|
||||
# Calcular estadísticas
|
||||
total = 0
|
||||
[:entradas, :nodos, :temas, :bitacoras].each do |tipo|
|
||||
if resultados[tipo].is_a?(Array)
|
||||
total += resultados[tipo].size
|
||||
end
|
||||
end
|
||||
|
||||
resultados[:estadisticas] = {
|
||||
total_resultados: total,
|
||||
texto_buscado: texto,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Crear índices básicos para optimización
|
||||
#
|
||||
# @return [Hash] Resultado de la operación
|
||||
def crear_indices_basicos
|
||||
indices = [
|
||||
# Índices para búsqueda por texto
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_descripcion ON bitacoras.entradas(descripcion)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_nodos_nombre ON bitacoras.nodos(nombre)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_temas_titulo ON bitacoras.temas(titulo)",
|
||||
|
||||
# Índices para filtrado frecuente
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_estado ON bitacoras.entradas(estado)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_created_at ON bitacoras.entradas(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_bitacoras_fecha ON bitacoras.bitacoras(fecha)",
|
||||
|
||||
# Índices para joins
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_nodo_id ON bitacoras.entradas(nodo_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_tema_id ON bitacoras.entradas(tema_id)"
|
||||
]
|
||||
|
||||
resultados = []
|
||||
indices.each do |sql|
|
||||
begin
|
||||
@conn.exec(sql)
|
||||
resultados << { indice: sql.split(' ON ').first, estado: 'creado' }
|
||||
@logger&.info("Índice creado: #{sql.split(' ON ').first}") if @logger
|
||||
rescue PG::Error => e
|
||||
resultados << { indice: sql.split(' ON ').first, estado: 'error', error: e.message }
|
||||
@logger&.error("Error creando índice: #{e.message}") if @logger
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
total_indices: indices.size,
|
||||
creados: resultados.count { |r| r[:estado] == 'creado' },
|
||||
errores: resultados.count { |r| r[:estado] == 'error' },
|
||||
detalles: resultados
|
||||
}
|
||||
end
|
||||
|
||||
# Obtener estadísticas básicas del sistema
|
||||
#
|
||||
# @return [Hash] Estadísticas
|
||||
def estadisticas_basicas
|
||||
estadisticas = {}
|
||||
|
||||
# Conteo por tabla
|
||||
tablas = ['nodos', 'bitacoras', 'temas', 'entradas']
|
||||
tablas.each do |tabla|
|
||||
begin
|
||||
count = @conn.exec("SELECT COUNT(*) as total FROM bitacoras.#{tabla}")[0]['total']
|
||||
estadisticas[tabla.to_sym] = count.to_i
|
||||
rescue => e
|
||||
estadisticas["#{tabla}_error".to_sym] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
# Entradas por estado
|
||||
begin
|
||||
query = "SELECT estado, COUNT(*) as total FROM bitacoras.entradas GROUP BY estado ORDER BY total DESC"
|
||||
resultados = @conn.exec(query)
|
||||
estadisticas[:entradas_por_estado] = resultados.to_a
|
||||
rescue => e
|
||||
estadisticas[:entradas_por_estado_error] = e.message
|
||||
end
|
||||
|
||||
# Entradas por modo
|
||||
begin
|
||||
query = "SELECT modo, COUNT(*) as total FROM bitacoras.entradas GROUP BY modo ORDER BY total DESC"
|
||||
resultados = @conn.exec(query)
|
||||
estadisticas[:entradas_por_modo] = resultados.to_a
|
||||
rescue => e
|
||||
estadisticas[:entradas_por_modo_error] = e.message
|
||||
end
|
||||
|
||||
# Entradas generadas por IA
|
||||
begin
|
||||
query = "SELECT COUNT(*) as total FROM bitacoras.entradas WHERE es_ia = true"
|
||||
count = @conn.exec(query)[0]['total']
|
||||
estadisticas[:entradas_ia] = count.to_i
|
||||
|
||||
total_entradas = estadisticas[:entradas] || 0
|
||||
if total_entradas > 0
|
||||
porcentaje = (count.to_f / total_entradas * 100).round(2)
|
||||
estadisticas[:porcentaje_ia] = porcentaje
|
||||
end
|
||||
rescue => e
|
||||
estadisticas[:entradas_ia_error] = e.message
|
||||
end
|
||||
|
||||
estadisticas[:timestamp] = Time.now.iso8601
|
||||
estadisticas
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Formatear resultados como texto legible
|
||||
def formatar_resultados_texto(resultados)
|
||||
return "No se encontraron resultados" if resultados.ntuples == 0
|
||||
|
||||
output = []
|
||||
output << "📊 Resultados encontrados: #{resultados.ntuples}"
|
||||
output << "=" * 60
|
||||
|
||||
resultados.each_with_index do |row, index|
|
||||
# Determinar título según la tabla
|
||||
titulo = row['descripcion'] || row['nombre'] || row['titulo'] || row['resumen'] || "Sin título"
|
||||
|
||||
output << "\n#{index + 1}. #{titulo[0..80]}#{'...' if titulo.length > 80}"
|
||||
output << " " + "-" * 40
|
||||
|
||||
# Mostrar campos relevantes
|
||||
campos_mostrar = []
|
||||
|
||||
if row['estado']
|
||||
emoji = case row['estado']
|
||||
when '⏳' then '⏳'
|
||||
when '✅' then '✅'
|
||||
when '❌' then '❌'
|
||||
when '⚠️' then '⚠️'
|
||||
when '⏸️' then '⏸️'
|
||||
else row['estado']
|
||||
end
|
||||
campos_mostrar << "Estado: #{emoji}"
|
||||
end
|
||||
|
||||
if row['nodo_nombre']
|
||||
campos_mostrar << "Nodo: #{row['nodo_nombre']}"
|
||||
elsif row['nodo_id']
|
||||
campos_mostrar << "Nodo ID: #{row['nodo_id']}"
|
||||
end
|
||||
|
||||
if row['tema_titulo']
|
||||
campos_mostrar << "Tema: #{row['tema_titulo']}"
|
||||
end
|
||||
|
||||
if row['bitacora_fecha']
|
||||
campos_mostrar << "Bitácora: #{row['bitacora_fecha']}"
|
||||
end
|
||||
|
||||
if row['inicio']
|
||||
hora = row['inicio'].to_s[0..4] # HH:MM
|
||||
campos_mostrar << "Inicio: #{hora}"
|
||||
end
|
||||
|
||||
if row['fin']
|
||||
hora = row['fin'].to_s[0..4] # HH:MM
|
||||
campos_mostrar << "Fin: #{hora}"
|
||||
end
|
||||
|
||||
if row['created_at']
|
||||
fecha = DateTime.parse(row['created_at'].to_s).strftime('%Y-%m-%d %H:%M')
|
||||
campos_mostrar << "Creado: #{fecha}"
|
||||
end
|
||||
|
||||
if row['fecha']
|
||||
campos_mostrar << "Fecha: #{row['fecha']}"
|
||||
end
|
||||
|
||||
if row['tipo']
|
||||
campos_mostrar << "Tipo: #{row['tipo']}"
|
||||
end
|
||||
|
||||
if row['activo']
|
||||
estado = row['activo'] == 't' ? 'Activo' : 'Inactivo'
|
||||
campos_mostrar << "Estado: #{estado}"
|
||||
end
|
||||
|
||||
if row['es_ia']
|
||||
es_ia = row['es_ia'] == 't' ? 'Sí' : 'No'
|
||||
campos_mostrar << "Generado por IA: #{es_ia}"
|
||||
end
|
||||
|
||||
# Mostrar campos (máximo 5 para no saturar)
|
||||
campos_mostrar.first(5).each do |campo|
|
||||
output << " • #{campo}"
|
||||
end
|
||||
|
||||
# Si hay más campos, mostrar indicador
|
||||
if campos_mostrar.size > 5
|
||||
output << " • ... (#{campos_mostrar.size - 5} campos más)"
|
||||
end
|
||||
end
|
||||
|
||||
output.join("\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,130 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'date'
|
||||
|
||||
module BitacorasDB
|
||||
module Exporters
|
||||
class MDExporter
|
||||
def initialize(db, logger = nil)
|
||||
@db = db
|
||||
@logger = logger || default_logger
|
||||
end
|
||||
|
||||
def exportar_bitacora(fecha, ruta_destino)
|
||||
@logger.info("Exportando bitácora #{fecha} a #{ruta_destino}...")
|
||||
|
||||
bitacora = @db.find_bitacora_by_fecha(fecha)
|
||||
unless bitacora
|
||||
@logger.error("No se encontró la bitácora para la fecha: #{fecha}")
|
||||
return false
|
||||
end
|
||||
|
||||
bitacora_id = bitacora['id']
|
||||
|
||||
# Obtener datos estructurados (incluyendo lo nuevo)
|
||||
entradas = @db.execute("
|
||||
SELECT e.*, n.nombre as nodo_nombre
|
||||
FROM bitacoras.entradas e
|
||||
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
|
||||
WHERE e.bitacora_id = $1
|
||||
ORDER BY n.nombre, e.inicio ASC
|
||||
", [bitacora_id])
|
||||
|
||||
gestion = @db.execute("
|
||||
SELECT g.*, n.nombre as nodo_nombre
|
||||
FROM bitacoras.gestion g
|
||||
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
|
||||
WHERE g.bitacora_id = $1 OR g.activo = true
|
||||
ORDER BY g.tipo, g.orden
|
||||
", [bitacora_id]) rescue [] # Por si la tabla no existe aún en otros entornos
|
||||
|
||||
resumenes = @db.execute("
|
||||
SELECT r.*, n.nombre as nodo_nombre
|
||||
FROM bitacoras.resumen_nodos r
|
||||
JOIN bitacoras.nodos n ON r.nodo_id = n.id
|
||||
WHERE r.bitacora_id = $1
|
||||
", [bitacora_id]) rescue []
|
||||
|
||||
# Generar contenido Markdown
|
||||
contenido = generar_markdown(fecha, bitacora['resumen'], gestion, resumenes, entradas)
|
||||
|
||||
File.write(ruta_destino, contenido)
|
||||
@logger.info("✅ Exportación completada con éxito.")
|
||||
true
|
||||
rescue => e
|
||||
@logger.error("Error exportando bitácora: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generar_markdown(fecha, resumen_gen, gestion, resumenes, entradas)
|
||||
y, m, d = fecha.to_s.split('-')
|
||||
md = String.new("<!-- GENERADO AUTOMÁTICAMENTE por adn db exportar:md — No editar manualmente -->\n")
|
||||
md << "# Bitácora de Operaciones - #{d}/#{m}/#{y}\n\n"
|
||||
|
||||
if gestion && gestion.ntuples > 0
|
||||
md << "## 📋 Control de Gestión\n\n"
|
||||
|
||||
pendientes = gestion.select { |g| g['tipo'] == 'pendiente' }
|
||||
if pendientes.any?
|
||||
md << "### 📝 Resumen de Actividades\n" # Formato ADN
|
||||
md << "| NODO | PENDIENTES |\n| :--- | :--- |\n"
|
||||
pendientes.each do |p|
|
||||
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
|
||||
end
|
||||
md << "\n"
|
||||
end
|
||||
|
||||
en_proceso = gestion.select { |g| g['tipo'] == 'proceso' }
|
||||
if en_proceso.any?
|
||||
md << "### ⏳ En Proceso\n"
|
||||
md << "| NODO | ACTIVIDAD |\n| :--- | :--- |\n"
|
||||
en_proceso.each do |p|
|
||||
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
|
||||
end
|
||||
md << "\n"
|
||||
end
|
||||
end
|
||||
|
||||
if resumenes && resumenes.ntuples > 0
|
||||
md << "### 📝 Resumen Integral\n"
|
||||
md << "| NODO | RESUMEN INTEGRAL |\n| :--- | :--- |\n"
|
||||
resumenes.each do |r|
|
||||
md << "| #{r['nodo_nombre']} | #{r['resumen']} |\n"
|
||||
end
|
||||
md << "\n"
|
||||
end
|
||||
|
||||
md << "## 📂 Actividades Detalladas\n\n"
|
||||
|
||||
por_nodo = entradas.group_by { |e| e['nodo_nombre'] || 'sin-nodo' }
|
||||
por_nodo.each do |nodo, entries|
|
||||
md << "### #{nodo}\n\n"
|
||||
md << "| I | F | Descripción | E |\n"
|
||||
md << "| :--- | :--- | :--- | :--- |\n"
|
||||
entries.each do |e|
|
||||
inicio = e['inicio'] ? e['inicio'][0..4] : '-'
|
||||
fin = e['fin'] ? e['fin'][0..4] : '-'
|
||||
ia = e['es_ia'] == 't' ? '(IA) ' : ''
|
||||
estado = "[#{e['modo']}] #{e['estado']}"
|
||||
md << "| #{inicio} | #{fin} | #{ia}#{e['descripcion']} | #{estado} |\n"
|
||||
end
|
||||
md << "\n"
|
||||
end
|
||||
|
||||
md << "---\n"
|
||||
md << "<!-- GENERADO AUTOMÁTICAMENTE desde PostgreSQL el #{Time.now.strftime('%Y-%m-%d %H:%M')} -->\n"
|
||||
md << "<!-- Fuente de verdad: DB. Regenerar con: adn db exportar:md #{fecha} -->\n"
|
||||
md
|
||||
end
|
||||
|
||||
def default_logger
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,639 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/importers/md_importer.rb — Importador de bitácoras .md a PostgreSQL
|
||||
# ================================================================================
|
||||
# Esta clase convierte bitácoras en formato Markdown al esquema de base de datos
|
||||
# dtic-BITACORAs. Procesa la estructura jerárquica:
|
||||
# Archivo .md → Bitácora → Nodos → Temas → Entradas (I-F-D-E)
|
||||
#
|
||||
# Formato de entrada (.md):
|
||||
# # Bitácora 2026-02-28
|
||||
# ## Control de Gestión
|
||||
# ### 📝 Resumen de Actividades
|
||||
# | NODO | RESUMEN INTEGRAL |
|
||||
# | :--- | :--- |
|
||||
# | srv-ns8 | Resumen del día... |
|
||||
#
|
||||
# ## 📂 Actividades Detalladas
|
||||
# ### srv-ns8
|
||||
# #### ✅ - SEC01 - Refactorización ns8-candados
|
||||
# | I | F | Descripción | E |
|
||||
# | :--- | :--- | :--- | :--- |
|
||||
# | 08:00 | 08:34 | Refactorización ns8-candados... | [P] ✅ |
|
||||
#
|
||||
# Uso:
|
||||
# importer = MDImporter.new(db_connection)
|
||||
# resultado = importer.importar_bitacora('bitacoras/2026-02-28.md')
|
||||
# resultado = importer.importar_todo_directorio('bitacoras/')
|
||||
#
|
||||
# Dependencias: Clase BitacoraDB del módulo BitacorasDB
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require 'yaml'
|
||||
require 'digest'
|
||||
|
||||
module BitacorasDB
|
||||
module Importers
|
||||
class MDImporter
|
||||
# Estados posibles en archivos .md y su mapeo a BD
|
||||
ESTADOS_MD = {
|
||||
'✅' => '✅',
|
||||
'❌' => '❌',
|
||||
'⚠️' => '⚠️',
|
||||
'⏸️' => '⏸️',
|
||||
'⏳' => '⏳',
|
||||
'👁️' => '⏳', # Sincronización mapea a ⏳
|
||||
'➡️' => '⏳', # Tarea continúa en otro día
|
||||
'🎭' => '✅' # Evento/Servicio
|
||||
}.freeze
|
||||
|
||||
# Modos posibles en archivos .md
|
||||
MODOS_MD = {
|
||||
'[P]' => 'P', # Presencial
|
||||
'[R]' => 'R', # Remoto
|
||||
'[P/R]' => 'P', # Presencial/Remoto -> Presencial por defecto
|
||||
'[S]' => 'S' # Servicio (No nodo técnico)
|
||||
}.freeze
|
||||
|
||||
# Tipos de nodo inferidos del nombre
|
||||
TIPOS_NODO = {
|
||||
/^srv-/ => 'servidor',
|
||||
/^dc-/ => 'vm',
|
||||
/^sql-/ => 'vm',
|
||||
/^pcv-/ => 'vm',
|
||||
/^pc-/ => 'pc',
|
||||
/^srvv-/ => 'vm',
|
||||
/^dtic-/ => 'servicio',
|
||||
/-pmox\d+$/ => 'hipervisor',
|
||||
/-xen\d+$/ => 'hipervisor'
|
||||
}.freeze
|
||||
|
||||
# Inicializa el importador con conexión a base de datos
|
||||
#
|
||||
# @param db [BitacoraDB] Instancia de BitacoraDB para operaciones
|
||||
# @param logger [Object] Logger para registro (opcional)
|
||||
def initialize(db, logger = nil)
|
||||
@db = db
|
||||
@logger = logger || default_logger
|
||||
@estadisticas = {
|
||||
archivos_procesados: 0,
|
||||
bitacoras_creadas: 0,
|
||||
nodos_creados: 0,
|
||||
temas_creados: 0,
|
||||
entradas_creadas: 0,
|
||||
errores: []
|
||||
}
|
||||
end
|
||||
|
||||
# Importa una bitácora específica desde archivo .md
|
||||
#
|
||||
# @param ruta_md [String] Ruta al archivo .md
|
||||
# @param opciones [Hash] Opciones de importación
|
||||
# :sobrescribir [Boolean] Sobrescribir bitácora si ya existe (default: false)
|
||||
# :crear_nodos [Boolean] Crear nodos automáticamente si no existen (default: true)
|
||||
# @return [Hash] Resultado de la importación con estadísticas
|
||||
def importar_bitacora(ruta_md, opciones = {})
|
||||
defaults = { sobrescribir: false, crear_nodos: true }
|
||||
opciones = defaults.merge(opciones)
|
||||
|
||||
@estadisticas[:archivos_procesados] += 1
|
||||
|
||||
begin
|
||||
# Leer y parsear archivo
|
||||
contenido = File.read(ruta_md, encoding: 'UTF-8')
|
||||
fecha = extraer_fecha_desde_ruta(ruta_md) || extraer_fecha_desde_contenido(contenido)
|
||||
|
||||
if fecha.nil?
|
||||
error = "No se pudo determinar fecha del archivo: #{ruta_md}"
|
||||
@logger.error(error)
|
||||
@estadisticas[:errores] << error
|
||||
return { error: error }
|
||||
end
|
||||
|
||||
@logger.info("Importando bitácora #{fecha} desde #{ruta_md}")
|
||||
|
||||
# Verificar si ya existe bitácora
|
||||
bitacora_existente = @db.find_bitacora_by_fecha(fecha)
|
||||
if bitacora_existente && !opciones[:sobrescribir]
|
||||
@logger.advertencia("Bitácora #{fecha} ya existe, omitiendo (use --sobrescribir)")
|
||||
return {
|
||||
skip: true,
|
||||
mensaje: "Bitácora ya existe",
|
||||
bitacora_id: bitacora_existente['id'],
|
||||
estadisticas: @estadisticas
|
||||
}
|
||||
end
|
||||
|
||||
# Parsear contenido
|
||||
estructura = parsear_contenido(contenido, fecha)
|
||||
|
||||
# Importar a base de datos
|
||||
resultado = importar_estructura(estructura, opciones)
|
||||
|
||||
resultado.merge(
|
||||
exitoso: resultado[:errores].empty?,
|
||||
fecha: fecha,
|
||||
estadisticas: @estadisticas.dup
|
||||
)
|
||||
|
||||
rescue => e
|
||||
error = "Error importando #{ruta_md}: #{e.message}"
|
||||
@logger.error(error)
|
||||
@estadisticas[:errores] << error
|
||||
{
|
||||
error: error,
|
||||
backtrace: e.backtrace,
|
||||
estadisticas: @estadisticas
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Importa todas las bitácoras de un directorio
|
||||
#
|
||||
# @param directorio [String] Ruta al directorio con archivos .md
|
||||
# @param opciones [Hash] Opciones de importación
|
||||
# @return [Hash] Resultado consolidado
|
||||
def importar_directorio(directorio, opciones = {})
|
||||
archivos = Dir.glob(File.join(directorio, '*.md')).sort
|
||||
resultados = []
|
||||
|
||||
@logger.info("Importando #{archivos.length} archivos desde #{directorio}")
|
||||
|
||||
archivos.each do |archivo|
|
||||
@logger.info("Procesando: #{File.basename(archivo)}")
|
||||
resultado = importar_bitacora(archivo, opciones)
|
||||
resultados << resultado.merge(archivo: archivo)
|
||||
end
|
||||
|
||||
consolidar_resultados(resultados)
|
||||
end
|
||||
|
||||
# Obtiene estadísticas de importación
|
||||
#
|
||||
# @return [Hash] Estadísticas acumuladas
|
||||
def estadisticas
|
||||
@estadisticas
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrae fecha desde la ruta del archivo (ej: bitacoras/2026-02-28.md)
|
||||
def extraer_fecha_desde_ruta(ruta)
|
||||
nombre = File.basename(ruta, '.md')
|
||||
begin
|
||||
Date.parse(nombre)
|
||||
rescue Date::Error
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Extrae fecha desde el contenido del archivo
|
||||
def extraer_fecha_desde_contenido(contenido)
|
||||
# Buscar patrón: # Bitácora 2026-02-28
|
||||
match = contenido.match(/# Bitácora (\d{4}-\d{2}-\d{2})/)
|
||||
return Date.parse(match[1]) if match
|
||||
|
||||
# Buscar patrón: # Bitácora de Operaciones - 28/02/2026
|
||||
match = contenido.match(/# Bitácora de Operaciones - (\d{2}\/\d{2}\/\d{4})/)
|
||||
if match
|
||||
fecha_str = match[1]
|
||||
dia, mes, anio = fecha_str.split('/')
|
||||
return Date.parse("#{anio}-#{mes}-#{dia}")
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Parsea el contenido del archivo .md a estructura jerárquica
|
||||
def parsear_contenido(contenido, fecha)
|
||||
lineas = contenido.lines.map(&:chomp)
|
||||
|
||||
estructura = {
|
||||
fecha: fecha,
|
||||
resumen: extraer_resumen(contenido),
|
||||
nodos: {},
|
||||
secciones: {}
|
||||
}
|
||||
|
||||
nodo_actual = nil
|
||||
tema_actual = nil
|
||||
en_actividades_detalladas = false
|
||||
en_tabla_entradas = false
|
||||
encabezados_tabla = []
|
||||
entradas_tabla = []
|
||||
|
||||
lineas.each_with_index do |linea, idx|
|
||||
# Detectar sección de actividades detalladas (con cualquier emoji o sin él)
|
||||
if linea.match?(/^## .*Actividades Detalladas/)
|
||||
en_actividades_detalladas = true
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar fin de sección (siguiente ##)
|
||||
if en_actividades_detalladas && linea.start_with?('## ') && !linea.match?(/.*Actividades Detalladas/)
|
||||
en_actividades_detalladas = false
|
||||
end
|
||||
|
||||
# Solo procesar dentro de actividades detalladas
|
||||
next unless en_actividades_detalladas
|
||||
|
||||
# Detectar nodo (### srv-ns8)
|
||||
if linea.start_with?('### ') && !linea.start_with?('####')
|
||||
nodo_nombre = linea.sub('### ', '').strip
|
||||
nodo_actual = nodo_nombre
|
||||
estructura[:nodos][nodo_actual] ||= {
|
||||
nombre: nodo_nombre,
|
||||
temas: {}
|
||||
}
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar tema (#### ✅ - SEC01 - Refactorización ns8-candados)
|
||||
if linea.start_with?('#### ')
|
||||
tema_linea = linea.sub('#### ', '').strip
|
||||
tema_info = parsear_tema(tema_linea)
|
||||
|
||||
if tema_info && nodo_actual
|
||||
tema_actual = tema_info[:id_tema]
|
||||
estructura[:nodos][nodo_actual][:temas][tema_actual] = {
|
||||
id_tema: tema_info[:id_tema],
|
||||
titulo: tema_info[:titulo],
|
||||
estado: tema_info[:estado],
|
||||
descripcion: tema_info[:descripcion],
|
||||
entradas: []
|
||||
}
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar inicio de tabla de entradas
|
||||
if linea.match?(/^\| I \| F \| Descripción \| (?:J \| )?E \|$/)
|
||||
en_tabla_entradas = true
|
||||
encabezados_tabla = linea.split('|').map(&:strip).reject(&:empty?)
|
||||
entradas_tabla = []
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar fin de tabla
|
||||
if en_tabla_entradas && (linea.empty? || linea.start_with?('#') || linea.start_with?('####'))
|
||||
# Procesar entradas acumuladas
|
||||
if tema_actual && nodo_actual && !entradas_tabla.empty?
|
||||
entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla)
|
||||
# Verificar que exista el tema antes de asignar entradas
|
||||
if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual]
|
||||
estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas
|
||||
else
|
||||
@logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas")
|
||||
end
|
||||
end
|
||||
|
||||
en_tabla_entradas = false
|
||||
encabezados_tabla = []
|
||||
entradas_tabla = []
|
||||
end
|
||||
|
||||
# Acumular líneas de tabla
|
||||
if en_tabla_entradas && linea.start_with?('|')
|
||||
entradas_tabla << linea
|
||||
end
|
||||
end
|
||||
|
||||
# Procesar última tabla si estaba activa al final del archivo
|
||||
if en_tabla_entradas && tema_actual && nodo_actual && !entradas_tabla.empty?
|
||||
entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla)
|
||||
# Verificar que exista el tema antes de asignar entradas
|
||||
if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual]
|
||||
estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas
|
||||
else
|
||||
@logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas")
|
||||
end
|
||||
end
|
||||
|
||||
estructura
|
||||
end
|
||||
|
||||
# Extrae resumen general del contenido
|
||||
def extraer_resumen(contenido)
|
||||
# Buscar en sección "Resumen de Actividades" con cualquier emoji o sin él
|
||||
patron_resumen = /### .*Resumen de Actividades/
|
||||
match_resumen = contenido.match(patron_resumen)
|
||||
|
||||
if match_resumen
|
||||
seccion = contenido.split(match_resumen[0])[1]
|
||||
if seccion
|
||||
# Tomar las primeras líneas después de la tabla
|
||||
lineas = seccion.lines.map(&:chomp)
|
||||
resumen_lineas = []
|
||||
en_tabla = false
|
||||
|
||||
lineas.each do |linea|
|
||||
if linea.match?(/^\|.*\|$/)
|
||||
en_tabla = true
|
||||
elsif en_tabla && !linea.match?(/^\|.*\|$/)
|
||||
# Fin de tabla, empezar a capturar resumen
|
||||
if linea.strip.length > 10 # Ignorar líneas muy cortas
|
||||
resumen_lineas << linea.strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return resumen_lineas.join(' ') if resumen_lineas.any?
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Parsea línea de tema (ej: "✅ - SEC01 - Refactorización ns8-candados")
|
||||
def parsear_tema(linea)
|
||||
# Intentar formato estándar: estado - id - título (ej: ✅ - ADN01 - Título)
|
||||
match_std = linea.match(/^([^\s-]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/)
|
||||
if match_std
|
||||
return {
|
||||
id_tema: match_std[2],
|
||||
titulo: match_std[3].strip,
|
||||
estado: ESTADOS_MD[match_std[1]] || '⏳',
|
||||
descripcion: match_std[3].strip
|
||||
}
|
||||
end
|
||||
|
||||
# Intentar formato simplificado: estado - título (ej: 🎭 - Servicio Auditorio)
|
||||
match_simple = linea.match(/^([^\s-]+)\s*-\s*(.+)$/)
|
||||
if match_simple
|
||||
titulo = match_simple[2].strip
|
||||
# Generar un ID determinista basado en el título para estabilidad
|
||||
id_gen = "GEN" + Digest::MD5.hexdigest(titulo)[0..5].upcase
|
||||
return {
|
||||
id_tema: id_gen,
|
||||
titulo: titulo,
|
||||
estado: ESTADOS_MD[match_simple[1]] || '⏳',
|
||||
descripcion: titulo
|
||||
}
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Parsea líneas de tabla de entradas
|
||||
def parsear_entradas_tabla(lineas_tabla, encabezados)
|
||||
entradas = []
|
||||
|
||||
# Mapeo de columnas basado en encabezados
|
||||
columnas = {}
|
||||
encabezados.each_with_index do |encabezado, idx|
|
||||
case encabezado.downcase
|
||||
when 'i' then columnas[:inicio] = idx
|
||||
when 'f' then columnas[:fin] = idx
|
||||
when 'descripción' then columnas[:descripcion] = idx
|
||||
when 'j' then columnas[:jornada] = idx
|
||||
when 'e' then columnas[:estado] = idx
|
||||
end
|
||||
end
|
||||
|
||||
lineas_tabla.each do |linea|
|
||||
# Saltar separadores de tabla Markdown (ej: | :--- | :--- | :--- | :--- |)
|
||||
next if linea.match?(/^[\|\s:-]+$/) # Solo pipes, espacios, guiones y dos puntos
|
||||
next if linea.match?(/^\|?\s*:?-+:?\s*\|/) # Separadores con :---, ---, :-:, etc.
|
||||
|
||||
# Limpiar pipes iniciales y finales antes de split
|
||||
linea_limpia = linea.strip.gsub(/^\||\|$/, '')
|
||||
celdas = linea_limpia.split('|').map(&:strip)
|
||||
next if celdas.length < 3 # Mínimo: inicio, descripción, estado
|
||||
|
||||
# Validar que la celda de inicio tenga contenido
|
||||
valor_inicio = celdas[columnas[:inicio]]
|
||||
next if valor_inicio.nil? || valor_inicio.empty? || valor_inicio.match?(/^:?-+:?$/)
|
||||
|
||||
fin_valor = nil
|
||||
if columnas[:fin] && !celdas[columnas[:fin]].nil?
|
||||
raw_fin = celdas[columnas[:fin]].strip
|
||||
fin_valor = raw_fin if !raw_fin.empty? && raw_fin != '-'
|
||||
end
|
||||
|
||||
entrada = {
|
||||
inicio: valor_inicio.gsub(/\s*\(.*?\)\s*/, '').strip,
|
||||
fin: fin_valor ? fin_valor.gsub(/\s*\(.*?\)\s*/, '').strip : nil,
|
||||
descripcion: celdas[columnas[:descripcion]],
|
||||
estado_modo: celdas[columnas[:estado]]
|
||||
}
|
||||
|
||||
# Parsear estado y modo
|
||||
if columnas[:jornada] && columnas[:estado]
|
||||
# Nuevo formato: J y E separados
|
||||
# Unimos temporalmente para usar parsear_estado_modo existente
|
||||
campo_combinado = "[#{celdas[columnas[:jornada]]}] #{celdas[columnas[:estado]]}"
|
||||
estado_modo = parsear_estado_modo(campo_combinado)
|
||||
entrada[:estado] = estado_modo[:estado]
|
||||
entrada[:modo] = estado_modo[:modo]
|
||||
elsif columnas[:estado]
|
||||
# Formato viejo o solo E
|
||||
estado_modo = parsear_estado_modo(celdas[columnas[:estado]])
|
||||
entrada[:estado] = estado_modo[:estado]
|
||||
entrada[:modo] = estado_modo[:modo]
|
||||
end
|
||||
|
||||
entradas << entrada
|
||||
end
|
||||
|
||||
entradas
|
||||
end
|
||||
|
||||
# Parsea campo estado/modo (ej: "[P] ✅")
|
||||
def parsear_estado_modo(campo)
|
||||
estado = '⏳'
|
||||
modo = 'P'
|
||||
|
||||
# Buscar estado en el campo
|
||||
ESTADOS_MD.each do |md_estado, bd_estado|
|
||||
if campo.include?(md_estado)
|
||||
estado = bd_estado
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Buscar modo en el campo
|
||||
MODOS_MD.each do |md_modo, bd_modo|
|
||||
if campo.include?(md_modo)
|
||||
modo = bd_modo
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
{ estado: estado, modo: modo }
|
||||
end
|
||||
|
||||
# Importa estructura parseada a base de datos
|
||||
def importar_estructura(estructura, opciones)
|
||||
errores = []
|
||||
|
||||
begin
|
||||
# 1. Crear o actualizar bitácora
|
||||
bitacora = @db.find_bitacora_by_fecha(estructura[:fecha])
|
||||
|
||||
if bitacora
|
||||
@logger.info("Bitácora #{estructura[:fecha]} ya existe (ID: #{bitacora['id']})")
|
||||
else
|
||||
bitacora = @db.create_bitacora(
|
||||
fecha: estructura[:fecha],
|
||||
resumen: estructura[:resumen]
|
||||
)
|
||||
@estadisticas[:bitacoras_creadas] += 1
|
||||
@logger.info("Bitácora creada: #{estructura[:fecha]} (ID: #{bitacora['id']})")
|
||||
end
|
||||
|
||||
bitacora_id = bitacora['id']
|
||||
|
||||
# 1b. Si se solicita sobrescribir, limpiar entradas previas de esta bitácora
|
||||
if opciones[:sobrescribir]
|
||||
@logger.info("Limpiando datos previos de la bitácora #{estructura[:fecha]}...")
|
||||
# Borrar entradas primero (FK)
|
||||
@db.execute("DELETE FROM bitacoras.entradas WHERE bitacora_id = $1", [bitacora_id])
|
||||
# Borrar temas (FK)
|
||||
@db.execute("DELETE FROM bitacoras.temas WHERE bitacora_id = $1", [bitacora_id])
|
||||
@estadisticas[:datos_limpiados] = true
|
||||
end
|
||||
|
||||
# 2. Procesar cada nodo
|
||||
estructura[:nodos].each do |nombre_nodo, datos_nodo|
|
||||
# Buscar o crear nodo
|
||||
nodo = @db.find_nodo_by_nombre(nombre_nodo)
|
||||
|
||||
if nodo.nil? && opciones[:crear_nodos]
|
||||
tipo = inferir_tipo_nodo(nombre_nodo)
|
||||
nodo = @db.create_nodo(
|
||||
nombre: nombre_nodo,
|
||||
tipo: tipo,
|
||||
descripcion: "Importado desde bitácora #{estructura[:fecha]}"
|
||||
)
|
||||
@estadisticas[:nodos_creados] += 1
|
||||
@logger.info("Nodo creado: #{nombre_nodo} (ID: #{nodo['id']})")
|
||||
elsif nodo.nil?
|
||||
@logger.advertencia("Nodo #{nombre_nodo} no existe y crear_nodos=false, omitiendo")
|
||||
next
|
||||
end
|
||||
|
||||
nodo_id = nodo['id']
|
||||
|
||||
# 3. Procesar temas del nodo
|
||||
datos_nodo[:temas].each do |id_tema, datos_tema|
|
||||
# Buscar tema por id_tema y bitácora
|
||||
temas = @db.list_temas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
|
||||
tema_existente = temas.find { |t| t['id_tema'] == id_tema }
|
||||
|
||||
if tema_existente
|
||||
@logger.info("Tema #{id_tema} ya existe (ID: #{tema_existente['id']})")
|
||||
tema_id = tema_existente['id']
|
||||
else
|
||||
tema = @db.create_tema(
|
||||
id_tema: id_tema,
|
||||
titulo: datos_tema[:titulo],
|
||||
estado: datos_tema[:estado],
|
||||
descripcion: datos_tema[:descripcion],
|
||||
bitacora_id: bitacora_id,
|
||||
nodo_id: nodo_id
|
||||
)
|
||||
tema_id = tema['id']
|
||||
@estadisticas[:temas_creados] += 1
|
||||
@logger.info("Tema creado: #{id_tema} (ID: #{tema_id})")
|
||||
end
|
||||
|
||||
# 4. Procesar entradas del tema
|
||||
datos_tema[:entradas].each do |entrada_md|
|
||||
# Validar campos requeridos
|
||||
next unless entrada_md[:inicio] && entrada_md[:descripcion]
|
||||
|
||||
# Intentar buscar entrada existente para evitar duplicados (Upsert)
|
||||
# Buscamos por: bitacora_id, nodo_id y hora de inicio (única para un nodo en un día)
|
||||
entradas_nodo = @db.list_entradas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
|
||||
# Normalizar hora para comparación
|
||||
inicio_norm = @db.send(:parse_time, entrada_md[:inicio])
|
||||
|
||||
existente = entradas_nodo.find { |e| e['inicio'] == inicio_norm }
|
||||
|
||||
begin
|
||||
if existente
|
||||
# Actualizar si ya existe
|
||||
@db.update_entrada(existente['id'], {
|
||||
fin: entrada_md[:fin],
|
||||
descripcion: entrada_md[:descripcion],
|
||||
estado: entrada_md[:estado] || '⏳',
|
||||
modo: entrada_md[:modo] || 'P'
|
||||
})
|
||||
@estadisticas[:entradas_actualizadas] ||= 0
|
||||
@estadisticas[:entradas_actualizadas] += 1
|
||||
else
|
||||
# Crear si no existe
|
||||
@db.create_entrada(
|
||||
inicio: entrada_md[:inicio],
|
||||
fin: entrada_md[:fin],
|
||||
descripcion: entrada_md[:descripcion],
|
||||
estado: entrada_md[:estado] || '⏳',
|
||||
modo: entrada_md[:modo] || 'P',
|
||||
es_ia: false,
|
||||
tema_id: tema_id,
|
||||
bitacora_id: bitacora_id,
|
||||
nodo_id: nodo_id
|
||||
)
|
||||
@estadisticas[:entradas_creadas] += 1
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error procesando entrada '#{entrada_md[:inicio]} - #{entrada_md[:descripcion]}': #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
rescue => e
|
||||
error = "Error importando estructura: #{e.message}"
|
||||
@logger.error(error)
|
||||
errores << error
|
||||
end
|
||||
|
||||
{ errores: errores }
|
||||
end
|
||||
|
||||
# Infiere tipo de nodo basado en su nombre
|
||||
def inferir_tipo_nodo(nombre)
|
||||
TIPOS_NODO.each do |patron, tipo|
|
||||
return tipo if nombre.match?(patron)
|
||||
end
|
||||
'servidor' # Tipo por defecto
|
||||
end
|
||||
|
||||
# Consolida resultados de múltiples importaciones
|
||||
def consolidar_resultados(resultados)
|
||||
totales = {
|
||||
archivos: resultados.length,
|
||||
exitosos: resultados.count { |r| r[:exitoso] == true },
|
||||
omitidos: resultados.count { |r| r[:skip] == true },
|
||||
errores: resultados.count { |r| r[:error] }
|
||||
}
|
||||
|
||||
estadisticas_consolidadas = {
|
||||
archivos_procesados: resultados.length,
|
||||
bitacoras_creadas: resultados.sum { |r| r.dig(:estadisticas, :bitacoras_creadas) || 0 },
|
||||
nodos_creados: resultados.sum { |r| r.dig(:estadisticas, :nodos_creados) || 0 },
|
||||
temas_creados: resultados.sum { |r| r.dig(:estadisticas, :temas_creados) || 0 },
|
||||
entradas_creadas: resultados.sum { |r| r.dig(:estadisticas, :entradas_creadas) || 0 },
|
||||
errores: resultados.flat_map { |r| r.dig(:estadisticas, :errores) || [] }.compact
|
||||
}
|
||||
|
||||
{
|
||||
totales: totales,
|
||||
estadisticas: estadisticas_consolidadas,
|
||||
detalles: resultados
|
||||
}
|
||||
end
|
||||
|
||||
# Logger por defecto
|
||||
def default_logger
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def advertencia(msg); puts "[ADVERTENCIA] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user