Files
GeorgeandClaude Sonnet 5 c2503745bf Reemplaza logging por consola/archivo, agrega historial de intentos y soporte para notas de crédito
- Nuevo logger_manager.py (reemplaza logger_utilities.py): logging con formato de árbol,
  rotación diaria a sunat.log, y consola por stdout (antes iba por stderr y se duplicaba
  en logs/error.log del launchd).
- Nuevo historial_manager.py: registra cada intento de consulta/descarga en historial.db
  para auditoría, con limpieza automática de registros de más de 180 días.
- consulta_cpe.py: corrige el id de notas de crédito/débito (usa F7/F8, no 07/08) y
  agrega reintentos ante errores 5xx.
- hana_manager.py: suma consulta a ORPC para notas de crédito (Indicator 07) y soporte
  de ObjType para actualizar la tabla correcta (OPCH/ORPC) al marcar PDF/XML descargado.
- gestor_fallas.py / main.py: propagan objtype end-to-end y dejan constancia de cada
  intento en historial_manager.
- Elimina login_api.py y reporte_fallas.py (código no usado / script manual sin uso reciente).
- Actualiza .gitignore (sunat-venv/, historial.db*) y docs/ para reflejar el estado real
  del proyecto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 12:31:48 -05:00

146 lines
5.0 KiB
Python

import logging
import re
import contextvars
import os
import sys
import time
from contextlib import contextmanager
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
LOG_DIR = Path(__file__).parent / "logs"
LOG_FILE = LOG_DIR / "sunat.log"
_LOG_CONFIGURED = False
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[])
# ============================================================================
class TreeFormatter(logging.Formatter):
def format(self, record):
return f"{self.formatTime(record)} ─── {record.getMessage()}"
def formatTime(self, record, datefmt=None):
import datetime
return datetime.datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
# ============================================================================
class PlainFileFormatter(TreeFormatter):
"""Igual que TreeFormatter pero sin códigos ANSI de color, para el archivo."""
def format(self, record):
return _ANSI_RE.sub("", super().format(record))
# ============================================================================
def _build_prefix(stack, is_last):
if not stack:
return ""
parts = []
for sib_is_last in stack[:-1]:
parts.append(" " if sib_is_last else "│ ")
parts.append("└── " if is_last else "├── ")
return "".join(parts)
# ============================================================================
class _TaskContext:
def __init__(self, logger, msg, emoji, is_last):
self._logger = logger
self._msg = msg
self._emoji = emoji
self._is_last = is_last
def __enter__(self):
stack = _stack.get()
prefix = _build_prefix(stack, self._is_last)
emoji_str = f"{self._emoji} " if self._emoji else ""
self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}")
_stack.set(stack + [self._is_last])
return TaskLogger(self._logger._logger)
def __exit__(self, exc_type, exc_val, exc_tb):
_stack.set(_stack.get()[:-1])
# ============================================================================
class TaskLogger:
def __init__(self, logger):
self._logger = logger
@contextmanager
def task(self, msg, emoji=""):
with _TaskContext(self, msg, emoji, is_last=False) as sub_logger:
yield sub_logger
@contextmanager
def last_task(self, msg, emoji=""):
with _TaskContext(self, msg, emoji, is_last=True) as sub_logger:
yield sub_logger
def info(self, msg, *args, _last=False, **kwargs):
self._log(logging.INFO, msg, args, kwargs, _last)
def warning(self, msg, *args, _last=False, **kwargs):
self._log(logging.WARNING, msg, args, kwargs, _last)
def error(self, msg, *args, _last=False, **kwargs):
self._log(logging.ERROR, msg, args, kwargs, _last)
def _log(self, level, msg, args, kwargs, is_last):
stack = _stack.get()
prefix = _build_prefix(stack, is_last)
self._logger.log(level, f"{prefix}{msg}", *args, **kwargs)
def _rotated_name(default_name):
"""TimedRotatingFileHandler entrega 'sunat.log.2026-08-30'; lo convertimos
a 'sunat_2026-08-30.log' para que sea más legible como nombre de archivo."""
date_part = default_name.rsplit(".", 1)[-1]
return str(LOG_FILE.with_name(f"{LOG_FILE.stem}_{date_part}{LOG_FILE.suffix}"))
# ============================================================================
def limpiar_logs_antiguos(dias=7):
"""Borra los logs rotados (sunat_YYYY-MM-DD.log) con más de 'dias' de antigüedad."""
limite = time.time() - dias * 86400
count = 0
for p in LOG_DIR.glob(f"{LOG_FILE.stem}_*{LOG_FILE.suffix}"):
try:
if p.stat().st_mtime < limite:
p.unlink()
count += 1
except OSError:
pass
return count
# ============================================================================
def setup_logging(level=None, console=True):
global _LOG_CONFIGURED
if _LOG_CONFIGURED:
return
if level is None:
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
LOG_DIR.mkdir(exist_ok=True)
file_handler = TimedRotatingFileHandler(
LOG_FILE,
when="midnight",
interval=1,
backupCount=0, # la retención la maneja limpiar_logs_antiguos() con el namer de abajo
encoding="utf-8",
)
file_handler.namer = _rotated_name
file_handler.setFormatter(PlainFileFormatter())
handlers = [file_handler]
if console:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(TreeFormatter())
handlers.append(console_handler)
logging.basicConfig(level=level, handlers=handlers, force=True)
_LOG_CONFIGURED = True
# ============================================================================
def get_logger(name="sunat"):
setup_logging()
return TaskLogger(logging.getLogger(name))