Files
descarga_xml_sunat/consulta_cpe.py
T

264 lines
9.0 KiB
Python
Raw Normal View History

2026-07-17 12:21:31 -05:00
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
2026-07-17 12:21:31 -05:00
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = get_logger(__name__)
2026-07-17 12:21:31 -05:00
# ============================================================================
2026-07-17 12:21:31 -05:00
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",
2026-07-17 12:21:31 -05:00
}
TIPO_DESCARGA = {"01": "PDF", "02": "XML", "03": "CDR"}
# ============================================================================
2026-07-17 12:21:31 -05:00
def load_token():
if not TOKEN_FILE.exists():
logger.error(f"No se encontró {TOKEN_FILE}. Ejecuta primero: python get_token.py")
2026-07-17 12:21:31 -05:00
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")
2026-07-17 12:21:31 -05:00
return None
remaining = expires - now
logger.info(f"Token válido por {int(remaining // 60)} min {int(remaining % 60)} seg")
2026-07-17 12:21:31 -05:00
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"}
2026-07-17 12:21:31 -05:00
def cpe_id(ruc, tipo, serie, numero):
tipo_url = TIPO_URL.get(tipo, tipo)
return f"{ruc}-{tipo_url}-{serie}-{numero}-2"
2026-07-17 12:21:31 -05:00
# ============================================================================
2026-07-17 12:21:31 -05:00
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",
2026-07-17 12:21:31 -05:00
"Origin": "https://e-factura.sunat.gob.pe",
"Referer": "https://e-factura.sunat.gob.pe/",
}
# ============================================================================
2026-07-17 12:21:31 -05:00
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})")
2026-07-17 12:21:31 -05:00
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")
2026-07-17 12:21:31 -05:00
except Exception as e:
if verbose:
logger.warning(f"Intento {intento+1} falló: {e}")
2026-07-17 12:21:31 -05:00
if intento < 2:
time.sleep(3)
else:
if verbose:
logger.error("Error después de 3 intentos")
return None, 0, None
2026-07-17 12:21:31 -05:00
if r is None:
return None, 0, None
2026-07-17 12:21:31 -05:00
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}")
2026-07-17 12:21:31 -05:00
if descargar:
for tipo_dl in ["02", "01", "03"]:
descargar_archivo(token, cid, tipo_dl, ts, verbose=verbose)
return data, 200, str(json_file)
2026-07-17 12:21:31 -05:00
elif r.status_code == 401:
if verbose:
logger.error("Token inválido. Ejecuta: python get_token.py")
2026-07-17 12:21:31 -05:00
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 404:
if verbose:
logger.error("No encontrado (404)")
2026-07-17 12:21:31 -05:00
else:
if verbose:
logger.error(f"Error {r.status_code}: {r.text[:300]}")
2026-07-17 12:21:31 -05:00
return None, r.status_code, None
2026-07-17 12:21:31 -05:00
# ============================================================================
2026-07-17 12:21:31 -05:00
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})")
2026-07-17 12:21:31 -05:00
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")
2026-07-17 12:21:31 -05:00
except Exception as e:
if verbose:
logger.warning(f"Intento {intento+1} falló: {e}")
2026-07-17 12:21:31 -05:00
if intento < 2:
time.sleep(3)
else:
if verbose:
logger.error("Error después de 3 intentos")
2026-07-17 12:21:31 -05:00
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")
2026-07-17 12:21:31 -05:00
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)")
2026-07-17 12:21:31 -05:00
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)")
2026-07-17 12:21:31 -05:00
except Exception as e:
if verbose:
logger.warning(f"Error extrayendo ZIP: {e}")
2026-07-17 12:21:31 -05:00
return True
elif r.status_code == 401:
if verbose:
logger.error("Token inválido (401)")
2026-07-17 12:21:31 -05:00
raise TokenExpiredError("Token expirado o inválido (401)")
elif r.status_code == 400:
if verbose:
logger.warning("No disponible (400)")
2026-07-17 12:21:31 -05:00
else:
if verbose:
logger.error(f"Error {r.status_code}")
2026-07-17 12:21:31 -05:00
return False
# ============================================================================
2026-07-17 12:21:31 -05:00
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)
# ============================================================================
2026-07-17 12:21:31 -05:00
if __name__ == "__main__":
main()