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:
GeorgeandClaude Sonnet 5 committed 2026-08-31 12:31:48 -05:00
1 parent 99a587c4da
commit c2503745bf
18 files changed
+647 -862

No files matched your search

+52 -33
View File
@@ -9,8 +9,10 @@ import requests
import urllib3
from pathlib import Path
from datetime import datetime
from logger_manager import get_logger
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = get_logger(__name__)
# ============================================================================
class TokenExpiredError(Exception):
@@ -25,7 +27,7 @@ BASE_PATH = "/v1/contribuyente/consultacpe/comprobantes"
TIPO_COMPROBANTE = {
"01": "Factura", "02": "Recibo por Honorarios", "03": "Boleta de Venta",
"04": "Liquidación de Compra", "07": "Nota de Crédito", "08": "Nota de Débito",
"04": "Liquidación de Compra", "07": "Factura - Nota de Crédito", "08": "Factura - Nota de Débito",
}
TIPO_DESCARGA = {"01": "PDF", "02": "XML", "03": "CDR"}
@@ -33,8 +35,7 @@ TIPO_DESCARGA = {"01": "PDF", "02": "XML", "03": "CDR"}
# ============================================================================
def load_token():
if not TOKEN_FILE.exists():
print(f"❌ No se encontró {TOKEN_FILE}")
print(" Ejecuta primero: python get_token.py")
logger.error(f"No se encontró {TOKEN_FILE}. Ejecuta primero: python get_token.py")
return None
data = json.loads(TOKEN_FILE.read_text())
@@ -43,16 +44,22 @@ def load_token():
now = time.time()
if expires and now > expires:
print(f"⚠️ Token expiró. Ejecuta de nuevo: python get_token.py")
logger.warning("Token expiró. Ejecuta de nuevo: python get_token.py")
return None
remaining = expires - now
print(f"🔑 Token válido por {int(remaining // 60)} min {int(remaining % 60)} seg")
logger.info(f"Token válido por {int(remaining // 60)} min {int(remaining % 60)} seg")
return token
# ============================================================================
# La API de consultacpe no acepta el código de Indicator de HANA tal cual para
# notas de crédito/débito: espera "F7"/"F8" en vez de "07"/"08" (confirmado
# contra la URL real que usa el formulario de SUNAT), sin importar la serie.
TIPO_URL = {"07": "F7", "08": "F8"}
def cpe_id(ruc, tipo, serie, numero):
return f"{ruc}-{tipo}-{serie}-{numero}-2"
tipo_url = TIPO_URL.get(tipo, tipo)
return f"{ruc}-{tipo_url}-{serie}-{numero}-2"
# ============================================================================
def get_headers(token):
@@ -70,8 +77,7 @@ def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbos
url = f"{API_HOST}{BASE_PATH}/{cid}"
if verbose:
print(f"\n🌐 Consultando: {ruc_emisor}-{tipo}-{serie}-{numero}")
print(f" {url}")
logger.info(f"Consultando: {ruc_emisor}-{tipo}-{serie}-{numero} ({url})")
session = requests.Session()
session.verify = False
@@ -82,20 +88,27 @@ def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbos
try:
r = session.get(url, timeout=120)
if verbose:
print(f"📡 {r.status_code}")
break
logger.info(f"Respuesta: {r.status_code}")
if r.status_code < 500:
break
if intento < 2:
if verbose:
logger.warning(f"Error {r.status_code}, reintentando...")
time.sleep(2 * (intento + 1))
elif verbose:
logger.error(f"Error {r.status_code} tras 3 intentos")
except Exception as e:
if verbose:
print(f" ⚠️ Intento {intento+1} falló: {e}")
logger.warning(f"Intento {intento+1} falló: {e}")
if intento < 2:
time.sleep(3)
else:
if verbose:
print(f" ❌ Error después de 3 intentos")
return None, 0
logger.error("Error después de 3 intentos")
return None, 0, None
if r is None:
return None, 0, 0
return None, 0, None
if r.status_code == 200:
data = r.json()
@@ -105,26 +118,26 @@ def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbos
json_file = DOWNLOAD_DIR / f"{safe}_{ts}.json"
json_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
if verbose:
print(f"💾 JSON: {json_file}")
logger.info(f"JSON: {json_file}")
if descargar:
for tipo_dl in ["02", "01", "03"]:
descargar_archivo(token, cid, tipo_dl, ts, verbose=verbose)
return data, 200
return data, 200, str(json_file)
elif r.status_code == 401:
if verbose:
print(" ❌ Token inválido. Ejecuta: python get_token.py")
logger.error("Token inválido. Ejecuta: python get_token.py")
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 404:
if verbose:
print(" ❌ No encontrado (404)")
logger.error("No encontrado (404)")
else:
if verbose:
print(f" ❌ Error {r.status_code}: {r.text[:300]}")
logger.error(f"Error {r.status_code}: {r.text[:300]}")
return None, r.status_code
return None, r.status_code, None
# ============================================================================
def descargar_archivo(token, cid, tipo, ts, verbose=True):
@@ -132,8 +145,7 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
url = f"{API_HOST}{BASE_PATH}/{cid}/{tipo}"
if verbose:
print(f"\n📥 Descargando {label}...")
print(f" {url}")
logger.info(f"Descargando {label}... ({url})")
session = requests.Session()
session.verify = False
@@ -144,16 +156,23 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
try:
r = session.get(url, timeout=120)
if verbose:
print(f"📡 {r.status_code}")
break
logger.info(f"Respuesta: {r.status_code}")
if r.status_code < 500:
break
if intento < 2:
if verbose:
logger.warning(f"Error {r.status_code}, reintentando...")
time.sleep(2 * (intento + 1))
elif verbose:
logger.error(f"Error {r.status_code} tras 3 intentos")
except Exception as e:
if verbose:
print(f" ⚠️ Intento {intento+1} falló: {e}")
logger.warning(f"Intento {intento+1} falló: {e}")
if intento < 2:
time.sleep(3)
else:
if verbose:
print(f" ❌ Error después de 3 intentos")
logger.error("Error después de 3 intentos")
return False
if r is None:
@@ -165,7 +184,7 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
if not val_archivo:
if verbose:
print(f" ⚠️ Respuesta sin valArchivo")
logger.warning("Respuesta sin valArchivo")
return False
raw = base64.b64decode(val_archivo)
@@ -175,7 +194,7 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
zip_path = DOWNLOAD_DIR / zip_name
zip_path.write_bytes(raw)
if verbose:
print(f" 💾 ZIP: {zip_path} ({len(raw)} bytes)")
logger.info(f"ZIP: {zip_path} ({len(raw)} bytes)")
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
@@ -200,23 +219,23 @@ def descargar_archivo(token, cid, tipo, ts, verbose=True):
out_path = DOWNLOAD_DIR / out_name
out_path.write_bytes(content)
if verbose:
print(f" ✅ {out_name} ({len(content)} bytes)")
logger.info(f"{out_name} ({len(content)} bytes)")
except Exception as e:
if verbose:
print(f" ⚠️ Error extrayendo ZIP: {e}")
logger.warning(f"Error extrayendo ZIP: {e}")
return True
elif r.status_code == 401:
if verbose:
print(" ❌ Token inválido (401)")
logger.error("Token inválido (401)")
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 400:
if verbose:
print(f" ⚠️ No disponible (400)")
logger.warning("No disponible (400)")
else:
if verbose:
print(f" ❌ Error {r.status_code}")
logger.error(f"Error {r.status_code}")
return False