- 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>
146 lines
5.0 KiB
Python
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)) |