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>
This commit is contained in:
1 parent
99a587c4da
commit
c2503745bf
18 files changed
+647
-862
No files matched your search
@@ -0,0 +1,146 @@
|
||||
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))
|
||||
Reference in new issue
Block a user