diff --git a/tools/adn/comando/cierre.rb b/tools/adn/comando/cierre.rb new file mode 100644 index 00000000..e27ac172 --- /dev/null +++ b/tools/adn/comando/cierre.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# tools/adn/comando/cierre.rb — Subcomando para cierre de jornada laboral (DB-First) +# ============================================================================== + +require 'date' +require 'time' +require_relative '../../bitacoras-db/core/bitacora_db' + +module ADN + class SubcomandoCierre + module Color + RESET = "\e[0m" + BOLD = "\e[1m" + GREEN = "\e[32m" + CYAN = "\e[36m" + YELLOW = "\e[33m" + RED = "\e[31m" + DIM = "\e[2m" + end + + def initialize(args, logger) + @args = args + @logger = logger + @fecha = Date.today + end + + def ejecutar + if @args.empty? || !(@args.include?('jornada')) + puts "#{Color::YELLOW}Uso: ./tools/adn/run cierre de jornada [presencial|remoto]#{Color::RESET}" + return + end + + modo = @args.include?('presencial') ? 'P' : 'R' + tipo_str = modo == 'P' ? 'Presencial' : 'Remoto' + hora_fin = Time.now.strftime('%H:%M') + + @logger.info("Iniciando cierre de jornada #{tipo_str} (Modo DB-First)") + + BitacorasDB::BitacoraDB.with_connection do |db| + # 1. Buscar la bitácora + bitacora = db.find_bitacora_by_fecha(@fecha) + unless bitacora + @logger.error("No existe bitácora en DB para hoy (#{@fecha}). ¿Iniciaste la jornada?") + return + end + + # 2. Obtener nodo srv-ns8 + nodo = db.find_nodo_by_nombre('srv-ns8') + + # 3. Crear entrada de cierre + db.create_entrada( + inicio: hora_fin, + fin: hora_fin, + descripcion: "Cierre de jornada #{tipo_str.downcase}. Sincronización final y validación de backups.", + estado: '✅', + modo: modo, + bitacora_id: bitacora['id'], + nodo_id: nodo ? nodo['id'] : nil + ) + + @logger.exito("Cierre de jornada registrado en Base de Datos.") + end + end + end +end diff --git a/tools/adn/comando/db.rb b/tools/adn/comando/db.rb index 546b4e90..eb7dd6d8 100644 --- a/tools/adn/comando/db.rb +++ b/tools/adn/comando/db.rb @@ -28,6 +28,7 @@ require 'time' begin require_relative '../../bitacoras-db/core/bitacora_db' require_relative '../../bitacoras-db/importers/md_importer' + require_relative '../../bitacoras-db/exporters/md_exporter' rescue LoadError => e # Fallback para desarrollo puts "⚠ Advertencia: No se pudo cargar BitacoraDB o MDImporter: #{e.message}" @@ -67,12 +68,20 @@ module ADN comando_evento_crear(sub_args) when 'evento:listar' comando_evento_listar(sub_args) + when 'evento:actualizar' + comando_evento_actualizar(sub_args) when 'nodo:crear' comando_nodo_crear(sub_args) when 'nodo:listar' comando_nodo_listar(sub_args) - when 'migrar:md' - comando_migrar_md(sub_args) + when 'hito:listar' + comando_hito_listar(sub_args) + when 'hito:actualizar' + comando_hito_actualizar(sub_args) + when 'hito:renombrar' + comando_hito_renombrar(sub_args) + when 'hito:ordenar' + comando_hito_ordenar(sub_args) when 'estadisticas' comando_estadisticas when 'backup' @@ -266,8 +275,8 @@ module ADN inicio: nil, descripcion: nil, fin: nil, - estado: '⏳', - modo: 'P', + estado: nil, + modo: nil, es_ia: false, nodo: nil, tema: nil @@ -370,6 +379,59 @@ module ADN end end + # Comando: evento:actualizar [OPCIONES] - Actualizar una entrada existente + def comando_evento_actualizar(args) + if args.length < 1 || args[0].start_with?('-') + puts "#{Color::YELLOW}Uso: adn db evento:actualizar [OPCIONES]#{Color::RESET}" + puts "Opciones soportadas: --fin \"HH:MM\", --inicio \"HH:MM\", --estado \"✅\", --descripcion \"Texto\"" + return + end + + id_evento = args.shift.to_i + opts = {} + + parser = OptionParser.new do |opt| + opt.banner = "Opciones:" + opt.on("--inicio HH:MM", "Nueva hora de inicio") { |i| opts[:inicio] = i } + opt.on("--fin HH:MM", "Nueva hora de fin") { |f| opts[:fin] = f } + opt.on("--descripcion TEXTO", "Nueva descripción") { |d| opts[:descripcion] = d } + opt.on("--estado ESTADO", "Nuevo estado (⏳, ✅, ❌, ⚠️, ⏸️)") { |e| opts[:estado] = e } + opt.on("-h", "--help", "Mostrar ayuda") do + puts opt + exit + end + end + + begin + parser.parse!(args) + rescue OptionParser::InvalidOption => e + puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" + exit 1 + end + + if opts.empty? + puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --fin, --inicio, --descripcion o --estado#{Color::RESET}" + return + end + + puts "#{Color::CYAN}🔄 Actualizando evento #{id_evento}...#{Color::RESET}" + + BitacorasDB::BitacoraDB.with_connection do |db| + begin + res = db.update_entrada(id_evento, opts) + if res + puts "#{Color::GREEN}✅ Evento #{id_evento} actualizado exitosamente.#{Color::RESET}" + puts " #{Color::BOLD}Inicio:#{Color::RESET} #{res['inicio']}" + puts " #{Color::BOLD}Fin:#{Color::RESET} #{res['fin'] || '(sin fin)'}" + else + puts "#{Color::YELLOW}⚠ No se pudo actualizar el evento. ¿Existe el ID #{id_evento}?#{Color::RESET}" + end + rescue => e + puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" + end + end + end + # Comando: evento:listar [OPCIONES] - Listar entradas con filtros def comando_evento_listar(args) opts = { limit: 20, offset: 0, nodo: nil, estado: nil, buscar: nil, desde: nil, hasta: nil } @@ -598,90 +660,216 @@ module ADN end end - # Comando: migrar:md [FECHA] - Migrar bitácora .md a base de datos - def comando_migrar_md(args) - opts = { sobrescribir: false, crear_nodos: true, todo: false } + # Comando: hito:listar [PROYECTO] + def comando_hito_listar(args) + if args.empty? + puts "#{Color::YELLOW}Uso: adn db hito:listar (ej: P2605)#{Color::RESET}" + return + end + + codigo = args[0] + puts "#{Color::CYAN}📋 Listando hitos del proyecto #{codigo.upcase}...#{Color::RESET}" + + BitacorasDB::BitacoraDB.with_connection do |db| + begin + hitos = db.list_hitos(codigo) + if hitos.empty? + puts "#{Color::YELLOW}⚠ No se encontraron hitos o el proyecto no existe.#{Color::RESET}" + return + end + + puts "" + puts "#{Color::BOLD}#{"Hito".ljust(8)} #{"Fase".ljust(6)} #{"Estado".ljust(8)} #{"Hrs P/R".ljust(10)} #{"Título"}#{Color::RESET}" + puts "#{"─" * 8} #{"─" * 6} #{"─" * 8} #{"─" * 10} #{"─" * 40}" + + hitos.each do |h| + hrs = "#{h['horas_presencial']}/#{h['horas_remoto']}" + puts "#{h['id_hito'].ljust(8)} #{h['fase_num'].to_s.ljust(6)} #{h['estado'].ljust(8)} #{hrs.ljust(10)} #{h['titulo']}" + end + puts "\nTotal: #{hitos.size} hitos" + rescue => e + puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" + end + end + end + + # Comando: hito:ordenar [--aplicar FILE] + def comando_hito_ordenar(args) + codigo = args.shift + if !codigo + puts "#{Color::YELLOW}Uso: adn db hito:ordenar [--aplicar /ruta/al/archivo.txt]#{Color::RESET}" + return + end + + aplicar_path = nil + if args[0] == '--aplicar' && args[1] + aplicar_path = args[1] + end + + BitacorasDB::BitacoraDB.with_connection do |db| + begin + hitos = db.list_hitos(codigo) + if hitos.empty? + puts "#{Color::YELLOW}⚠ No se encontraron hitos para #{codigo.upcase}.#{Color::RESET}" + return + end + + if aplicar_path + unless File.exist?(aplicar_path) + puts "#{Color::RED}✗ No se encontró el archivo: #{aplicar_path}#{Color::RESET}" + return + end + + # Procesar el archivo y aplicarrenombramientos lógicos "Fase.Grupo.Paso" + puts "#{Color::CYAN}📋 Aplicando ordenamiento desde #{aplicar_path}...#{Color::RESET}" + lineas = File.readlines(aplicar_path).map(&:strip).reject { |l| l.empty? || l.start_with?('#') } + + # Agrupar por fase según el orden de las líneas + hitos_por_fase = Hash.new { |h, k| h[k] = [] } + lineas.each do |linea| + if linea =~ /^(\d+):\s*\[.+?\]\s*(.+)$/ + id_db = $1.to_i + hito = hitos.find { |h| h['id'] == id_db } + hitos_por_fase[hito['fase_num']] << hito if hito + end + end + + # Iniciar renombrado secuencial + hitos_por_fase.each do |fase, hitos_fase| + grupo = 1 + letra = 65 # 'A' + + hitos_fase.each do |h| + nuevo_id = "#{fase}.#{grupo}.#{letra.chr}" + + if h['id_hito'] != nuevo_id + db.rename_hito_id(codigo, h['id_hito'], nuevo_id) + puts " #{Color::GREEN}✔ #{h['id_hito'].ljust(8)} → #{nuevo_id.ljust(8)}#{Color::RESET} | #{h['titulo']}" + else + puts " #{Color::DIM}• #{nuevo_id.ljust(8)} (Sin cambios) | #{h['titulo']}#{Color::RESET}" + end + + letra += 1 + if letra > 67 # 'C' + letra = 65 + grupo += 1 + end + end + end + puts "#{Color::GREEN}✅ Ordenamiento aplicado exitosamente a #{codigo.upcase}.#{Color::RESET}" + + else + # Generar archivo temporal para reordenamiento (Swarm-Ready) + tmp_file = "/tmp/adn_ordenar_#{codigo.downcase}.txt" + File.open(tmp_file, 'w') do |f| + f.puts "# ===========================================================================" + f.puts "# MODO ORDENAMIENTO DE HITOS - PROYECTO #{codigo.upcase}" + f.puts "# ===========================================================================" + f.puts "# Cambia el orden de las líneas (Cortar y Pegar) dentro de la misma FASE" + f.puts "# para definir la secuencia lógica de ejecución." + f.puts "# NO MODIFIQUES el número inicial ni los corchetes (ej: '42: [F3]')." + f.puts "# Cuando termines de editar, guarda el archivo y ejecuta en la terminal:" + f.puts "# adn db hito:ordenar #{codigo.upcase} --aplicar #{tmp_file}" + f.puts "# ===========================================================================\n\n" + + fases = hitos.group_by { |h| h['fase_num'] }.sort_by { |k, v| k } + fases.each do |num_fase, hitos_fase| + f.puts "# --- FASE #{num_fase} ---" + hitos_fase.each do |h| + f.puts "#{h['id']}: [F#{num_fase}] #{h['titulo']}" + end + f.puts "\n" + end + end + + puts "#{Color::CYAN}✅ Archivo de configuración generado en: #{tmp_file}#{Color::RESET}" + puts "1. Abre el archivo y reordena las líneas como necesites." + puts "2. Ejecuta: #{Color::BOLD}adn db hito:ordenar #{codigo.upcase} --aplicar #{tmp_file}#{Color::RESET}" + end + rescue => e + puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" + end + end + end + + # Comando: hito:renombrar + def comando_hito_renombrar(args) + if args.length < 3 + puts "#{Color::YELLOW}Uso: adn db hito:renombrar #{Color::RESET}" + return + end + + proyecto = args[0] + id_actual = args[1] + id_nuevo = args[2] + + puts "#{Color::CYAN}🔄 Renombrando hito #{id_actual} → #{id_nuevo} (Proyecto #{proyecto.upcase})...#{Color::RESET}" + + BitacorasDB::BitacoraDB.with_connection do |db| + begin + res = db.rename_hito_id(proyecto, id_actual, id_nuevo) + puts "#{Color::GREEN}✅ Hito renombrado exitosamente. Título: #{res['titulo']}#{Color::RESET}" + rescue => e + puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" + end + end + end + + # Comando: hito:actualizar [OPCIONES] + def comando_hito_actualizar(args) + if args.length < 2 + puts "#{Color::YELLOW}Uso: adn db hito:actualizar [OPCIONES]#{Color::RESET}" + puts "Opciones soportadas: --estado=EJ, --horas-p=1.5, --horas-r=0, --desc=\"Texto\"" + return + end + + proyecto = args.shift + id_hito = args.shift + opts = { estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil } parser = OptionParser.new do |opt| - opt.banner = "Uso: adn db migrar:md [FECHA] [OPCIONES]" - opt.on("--sobrescribir", "Sobrescribir bitácora si ya existe") do - opts[:sobrescribir] = true - end - opt.on("--no-crear-nodos", "No crear nodos automáticamente") do - opts[:crear_nodos] = false - end - opt.on("--todo", "Importar todas las bitácoras del directorio") do - opts[:todo] = true - end - opt.on("-h", "--help", "Mostrar ayuda") do - puts opt - exit - end + opt.banner = "Opciones:" + opt.on("--estado ESTADO", "Nuevo estado (ej: ✅, ⏳, 📋)") { |e| opts[:estado] = e } + opt.on("--horas-p HORAS", "Horas presenciales") { |h| opts[:horas_presencial] = h.to_f } + opt.on("--horas-r HORAS", "Horas remotas") { |h| opts[:horas_remoto] = h.to_f } + opt.on("--desc DESC", "Descripción o detalle para el hito") { |d| opts[:descripcion] = d } end begin parser.parse!(args) rescue OptionParser::InvalidOption => e puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" - puts parser exit 1 end - # Si se especificó --todo, importar todas las bitácoras - if opts[:todo] - importar_todas_bitacoras(opts) + if opts.values.all?(&:nil?) + puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --estado, --horas-p, --horas-r o --desc#{Color::RESET}" return end - # Si no quedan argumentos, mostrar ayuda - if args.empty? - puts "#{Color::YELLOW}⚠ Se requiere fecha o --todo#{Color::RESET}" - puts parser - exit 1 - end - - fecha_arg = args[0] - - begin - fecha = Date.parse(fecha_arg) - rescue Date::Error - puts "#{Color::RED}✗ Formato de fecha inválido: #{fecha_arg}#{Color::RESET}" - puts " Usa formato YYYY-MM-DD (ej: 2026-03-07)" - exit 1 - end - - archivo_md = File.join(ADN::BITACORAS_DIR, "#{fecha}.md") - - unless File.exist?(archivo_md) - puts "#{Color::RED}✗ Archivo de bitácora no encontrado: #{archivo_md}#{Color::RESET}" - puts " Verifica que la bitácora .md existe en #{ADN::BITACORAS_DIR}/" - exit 1 - end - - puts "#{Color::CYAN}🔄 Migrando bitácora #{fecha} de .md a base de datos...#{Color::RESET}" - puts " #{Color::DIM}Opciones: sobrescribir=#{opts[:sobrescribir]}, crear_nodos=#{opts[:crear_nodos]}#{Color::RESET}" + puts "#{Color::CYAN}🔄 Actualizando hito #{id_hito} del proyecto #{proyecto.upcase}...#{Color::RESET}" BitacorasDB::BitacoraDB.with_connection do |db| begin - # Crear instancia del importador - importer = BitacorasDB::Importers::MDImporter.new(db, @logger) - - # Importar bitácora - resultado = importer.importar_bitacora( - archivo_md, - sobrescribir: opts[:sobrescribir], - crear_nodos: opts[:crear_nodos] + res = db.update_hito( + proyecto, id_hito, + estado: opts[:estado], + horas_presencial: opts[:horas_presencial], + horas_remoto: opts[:horas_remoto], + descripcion: opts[:descripcion] ) - - mostrar_resultado_migracion(resultado, fecha) + puts "#{Color::GREEN}✅ Hito actualizado exitosamente.#{Color::RESET}" + hrs = "#{res['horas_presencial'].to_f}P / #{res['horas_remoto'].to_f}R" + puts " Estado actual : #{res['estado']}" + puts " Horas imputadas : #{hrs}" rescue => e - puts "#{Color::RED}✗ Error durante migración: #{e.message}#{Color::RESET}" - puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG'] + puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}" end end end - # Comando: estadisticas - Mostrar estadísticas del sistema + + def comando_estadisticas puts "#{Color::CYAN}📊 Estadísticas del sistema dtic-BITACORAs#{Color::RESET}" @@ -817,91 +1005,7 @@ module ADN end - # Importa todas las bitácoras del directorio bitacoras/ - def importar_todas_bitacoras(opciones) - puts "#{Color::CYAN}🔄 Migrando todas las bitácoras .md a base de datos...#{Color::RESET}" - puts " #{Color::DIM}Opciones: sobrescribir=#{opciones[:sobrescribir]}, crear_nodos=#{opciones[:crear_nodos]}#{Color::RESET}" - puts "" - - BitacorasDB::BitacoraDB.with_connection do |db| - # Crear instancia del importador - importer = BitacorasDB::Importers::MDImporter.new(db, @logger) - - # Importar directorio completo - resultado = importer.importar_directorio( - ADN::BITACORAS_DIR, - sobrescribir: opciones[:sobrescribir], - crear_nodos: opciones[:crear_nodos] - ) - - # Mostrar resultados consolidados - totales = resultado[:totales] - estadisticas = resultado[:estadisticas] - - puts "" - puts "#{Color::BOLD}📊 Resultado de migración masiva:#{Color::RESET}" - puts " #{Color::DIM}Archivos procesados:#{Color::RESET} #{totales[:archivos]}" - puts " #{Color::DIM}Migraciones exitosas:#{Color::RESET} #{totales[:exitosos]}" - puts " #{Color::DIM}Omitidos (ya existían):#{Color::RESET} #{totales[:omitidos]}" - puts " #{Color::DIM}Errores:#{Color::RESET} #{totales[:errores]}" - - if estadisticas[:errores].any? - puts "" - puts "#{Color::YELLOW}⚠ Errores encontrados:#{Color::RESET}" - estadisticas[:errores].each_with_index do |error, idx| - puts " #{idx + 1}. #{error}" - end - end - - puts "" - puts "#{Color::BOLD}📈 Estadísticas acumuladas:#{Color::RESET}" - puts " #{Color::DIM}Bitácoras creadas:#{Color::RESET} #{estadisticas[:bitacoras_creadas]}" - puts " #{Color::DIM}Nodos creados:#{Color::RESET} #{estadisticas[:nodos_creados]}" - puts " #{Color::DIM}Temas creados:#{Color::RESET} #{estadisticas[:temas_creados]}" - puts " #{Color::DIM}Entradas creadas:#{Color::RESET} #{estadisticas[:entradas_creadas]}" - - if totales[:errores] > 0 - puts "" - puts "#{Color::YELLOW}⚠ Algunas bitácoras no se migraron correctamente#{Color::RESET}" - puts " Revisa los errores arriba y corrige los problemas" - elsif totales[:exitosos] > 0 - puts "" - puts "#{Color::GREEN}✅ Migración masiva completada exitosamente#{Color::RESET}" - end - end - rescue => e - puts "#{Color::RED}✗ Error durante migración masiva: #{e.message}#{Color::RESET}" - puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG'] - end - - # Muestra el resultado de una migración individual - def mostrar_resultado_migracion(resultado, fecha) - if resultado[:exitoso] - puts "#{Color::GREEN}✅ Bitácora migrada exitosamente#{Color::RESET}" - puts "" - puts " #{Color::BOLD}Fecha:#{Color::RESET} #{fecha}" - puts " #{Color::BOLD}Bitácora ID:#{Color::RESET} #{resultado[:bitacora_id]}" if resultado[:bitacora_id] - puts " #{Color::BOLD}Estadísticas:#{Color::RESET}" - puts " • Nodos creados: #{resultado.dig(:estadisticas, :nodos_creados) || 0}" - puts " • Temas creados: #{resultado.dig(:estadisticas, :temas_creados) || 0}" - puts " • Entradas creadas: #{resultado.dig(:estadisticas, :entradas_creadas) || 0}" - elsif resultado[:skip] - puts "#{Color::YELLOW}⚠ Bitácora ya existe, omitiendo#{Color::RESET}" - puts " Usa --sobrescribir para forzar migración" - puts " Bitácora ID: #{resultado[:bitacora_id]}" if resultado[:bitacora_id] - else - puts "#{Color::RED}✗ Error migrando bitácora#{Color::RESET}" - if resultado[:error] - puts " #{resultado[:error]}" - end - if resultado[:errores]&.any? - resultado[:errores].each do |error| - puts " • #{error}" - end - end - end - end # ─── MÉTODOS AUXILIARES ────────────────────────────────────────────── @@ -932,7 +1036,6 @@ module ADN puts " #{Color::CYAN}evento:listar [OPCIONES]#{Color::RESET} Listar entradas con filtros" puts " #{Color::CYAN}nodo:crear [OPCIONES]#{Color::RESET} Crear nuevo nodo (servidor/VM/PC)" puts " #{Color::CYAN}nodo:listar [OPCIONES]#{Color::RESET} Listar nodos del sistema" - puts " #{Color::CYAN}migrar:md [FECHA]#{Color::RESET} Migrar bitácora .md a base de datos" puts " #{Color::CYAN}estadisticas#{Color::RESET} Mostrar estadísticas del sistema" puts "" puts "#{Color::BOLD}Ejemplos:#{Color::RESET}" diff --git a/tools/adn/comando/inicio.rb b/tools/adn/comando/inicio.rb new file mode 100644 index 00000000..3f5c21ee --- /dev/null +++ b/tools/adn/comando/inicio.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# tools/adn/comando/inicio.rb — Subcomando para inicio de jornada laboral (DB-First) +# ============================================================================== + +require 'date' +require 'time' +require_relative '../../bitacoras-db/core/bitacora_db' + +module ADN + class SubcomandoInicio + module Color + RESET = "\e[0m" + BOLD = "\e[1m" + GREEN = "\e[32m" + CYAN = "\e[36m" + YELLOW = "\e[33m" + RED = "\e[31m" + DIM = "\e[2m" + end + + def initialize(args, logger) + @args = args + @logger = logger + @fecha = Date.today + end + + def ejecutar + if @args.empty? || !(@args.include?('jornada')) + puts "#{Color::YELLOW}Uso: ./tools/adn/run inicio de jornada [presencial|remoto]#{Color::RESET}" + return + end + + modo = @args.include?('presencial') ? 'P' : 'R' + tipo_str = modo == 'P' ? 'Presencial' : 'Remoto' + hora_ini = Time.now.strftime('%H:%M') + + @logger.info("Iniciando jornada #{tipo_str} (Modo DB-First)") + + BitacorasDB::BitacoraDB.with_connection do |db| + # 1. Asegurar que existe la bitácora + bitacora = db.find_bitacora_by_fecha(@fecha) + unless bitacora + @logger.info("Creando bitácora en DB para hoy...") + bitacora = db.create_bitacora(fecha: @fecha) + end + + # 2. Obtener nodo srv-ns8 + nodo = db.find_nodo_by_nombre('srv-ns8') + unless nodo + @logger.warn("Nodo 'srv-ns8' no encontrado, usando nodo general...") + # Podríamos crearlo o buscar uno por defecto + end + + # 3. Crear entrada de inicio + db.create_entrada( + inicio: hora_ini, + descripcion: "Inicio de jornada #{tipo_str.downcase}. Sincronización de contexto y revisión de tareas.", + estado: '👁️', + modo: modo, + bitacora_id: bitacora['id'], + nodo_id: nodo ? nodo['id'] : nil + ) + + @logger.exito("Entrada de inicio creada en Base de Datos.") + end + end + end +end diff --git a/tools/bitacoras-db/core/bitacora_db.rb b/tools/bitacoras-db/core/bitacora_db.rb index 0a2ed962..a63ca0d8 100644 --- a/tools/bitacoras-db/core/bitacora_db.rb +++ b/tools/bitacoras-db/core/bitacora_db.rb @@ -46,7 +46,7 @@ module BitacorasDB ESTADOS_VALIDOS = ['⏳', '✅', '❌', '⚠️', '⏸️'].freeze # Modos válidos para entradas - MODOS_VALIDOS = ['P', 'R'].freeze # P=Presencial, R=Remoto + MODOS_VALIDOS = ['P', 'R', 'S'].freeze # P=Presencial, R=Remoto, S=Servicio # Inicializa la conexión a la base de datos # @@ -281,7 +281,7 @@ module BitacorasDB # @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'] + 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(', ')}" @@ -365,13 +365,28 @@ module BitacorasDB # @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: '⏳', modo: 'P', es_ia: false, + 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(', ')}" @@ -400,6 +415,44 @@ module BitacorasDB 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 @@ -630,6 +683,107 @@ module BitacorasDB 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