Files
descarga_xml_sunat/consulta_cpe.py
T
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

265 lines
9.0 KiB
Python

import os
import json
import time
import argparse
import base64
import zipfile
import io
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):
pass
TOKEN_FILE = Path(__file__).parent / "token.json"
DOWNLOAD_DIR = Path(__file__).parent / "descargas"
DOWNLOAD_DIR.mkdir(exist_ok=True)
API_HOST = "https://api-cpe.sunat.gob.pe"
BASE_PATH = "/v1/contribuyente/consultacpe/comprobantes"
TIPO_COMPROBANTE = {
"01": "Factura", "02": "Recibo por Honorarios", "03": "Boleta de Venta",
"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"}
# ============================================================================
def load_token():
if not TOKEN_FILE.exists():
logger.error(f"No se encontró {TOKEN_FILE}. Ejecuta primero: python get_token.py")
return None
data = json.loads(TOKEN_FILE.read_text())
token = data.get("token")
expires = data.get("expires_at", 0)
now = time.time()
if expires and now > expires:
logger.warning("Token expiró. Ejecuta de nuevo: python get_token.py")
return None
remaining = expires - now
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):
tipo_url = TIPO_URL.get(tipo, tipo)
return f"{ruc}-{tipo_url}-{serie}-{numero}-2"
# ============================================================================
def get_headers(token):
return {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
"Origin": "https://e-factura.sunat.gob.pe",
"Referer": "https://e-factura.sunat.gob.pe/",
}
# ============================================================================
def consultar_cpe(token, ruc_emisor, tipo, serie, numero, descargar=True, verbose=True):
cid = cpe_id(ruc_emisor, tipo, serie, numero)
url = f"{API_HOST}{BASE_PATH}/{cid}"
if verbose:
logger.info(f"Consultando: {ruc_emisor}-{tipo}-{serie}-{numero} ({url})")
session = requests.Session()
session.verify = False
session.headers.update(get_headers(token))
r = None
for intento in range(3):
try:
r = session.get(url, timeout=120)
if verbose:
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:
logger.warning(f"Intento {intento+1} falló: {e}")
if intento < 2:
time.sleep(3)
else:
if verbose:
logger.error("Error después de 3 intentos")
return None, 0, None
if r is None:
return None, 0, None
if r.status_code == 200:
data = r.json()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe = cid.replace("/", "_")
json_file = DOWNLOAD_DIR / f"{safe}_{ts}.json"
json_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
if verbose:
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, str(json_file)
elif r.status_code == 401:
if verbose:
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:
logger.error("No encontrado (404)")
else:
if verbose:
logger.error(f"Error {r.status_code}: {r.text[:300]}")
return None, r.status_code, None
# ============================================================================
def descargar_archivo(token, cid, tipo, ts, verbose=True):
label = TIPO_DESCARGA.get(tipo, tipo)
url = f"{API_HOST}{BASE_PATH}/{cid}/{tipo}"
if verbose:
logger.info(f"Descargando {label}... ({url})")
session = requests.Session()
session.verify = False
session.headers.update(get_headers(token))
r = None
for intento in range(3):
try:
r = session.get(url, timeout=120)
if verbose:
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:
logger.warning(f"Intento {intento+1} falló: {e}")
if intento < 2:
time.sleep(3)
else:
if verbose:
logger.error("Error después de 3 intentos")
return False
if r is None:
return False
if r.status_code == 200:
data = r.json()
val_archivo = data.get("valArchivo", "")
if not val_archivo:
if verbose:
logger.warning("Respuesta sin valArchivo")
return False
raw = base64.b64decode(val_archivo)
file_base = cid[:-2] if cid.endswith("-2") else cid
zip_name = f"{file_base}-{label}.zip"
zip_path = DOWNLOAD_DIR / zip_name
zip_path.write_bytes(raw)
if verbose:
logger.info(f"ZIP: {zip_path} ({len(raw)} bytes)")
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
for member in zf.namelist():
content = zf.read(member)
member_lower = member.lower()
if label == "PDF" and member_lower.endswith(".pdf"):
out_name = f"{file_base}.pdf"
elif label == "XML":
if not member_lower.endswith(".xml"):
continue
if member.startswith("R-") or member.startswith("r-"):
out_name = f"CDR-{file_base}.xml"
else:
out_name = f"{file_base}.xml"
elif label == "CDR" and member_lower.endswith(".xml"):
out_name = f"CDR-{file_base}.xml"
else:
continue
out_path = DOWNLOAD_DIR / out_name
out_path.write_bytes(content)
if verbose:
logger.info(f"{out_name} ({len(content)} bytes)")
except Exception as e:
if verbose:
logger.warning(f"Error extrayendo ZIP: {e}")
return True
elif r.status_code == 401:
if verbose:
logger.error("Token inválido (401)")
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 400:
if verbose:
logger.warning("No disponible (400)")
else:
if verbose:
logger.error(f"Error {r.status_code}")
return False
# ============================================================================
def main():
print("=" * 60)
print("🔍 CONSULTA CPE - SUNAT API")
print("=" * 60)
parser = argparse.ArgumentParser(description="Consultar CPE en SUNAT")
parser.add_argument("--ruc", required=True, help="RUC del emisor")
parser.add_argument("--tipo", required=True, help="Tipo (01=Factura, 03=Boleta, 07=NC, 08=ND)")
parser.add_argument("--serie", required=True, help="Serie (ej: F007)")
parser.add_argument("--numero", required=True, help="Número del comprobante")
parser.add_argument("--no-download", action="store_true", help="No descargar archivos")
args = parser.parse_args()
token = load_token()
if not token:
return
consultar_cpe(token, args.ruc, args.tipo, args.serie, args.numero, descargar=not args.no_download)
# ============================================================================
if __name__ == "__main__":
main()