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
+52
-33
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user