- 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>
265 lines
9.0 KiB
Python
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()
|