Files
plantillas-proyectos/backend/api/v1/modules/a76/factura_cove/external_service.py
2026-04-30 11:07:03 -06:00

214 lines
8.5 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict
import logging
from urllib.parse import urljoin
import httpx
from core.config import settings
from .schemas import FacturaCoveRequest
logger = logging.getLogger(__name__)
@dataclass
class CoveExternalResult:
"""Resultado normalizado de POST /generar-factura-cove contra el API de VU."""
status: str
message: str | None = None
cove_number: str | None = None
vucem_operation_num: str | None = None
# XML de COVE devuelto por VU codificado en base64 (campo envio_base64 del result)
cove_xml_base64: str | None = None
raw_response: Dict[str, Any] | None = None
class CoveExternalService:
"""
Cliente del API externo de COVE.
Implementación real:
- POST /api/v1/factura-cove/generar-factura-cove para iniciar proceso.
- GET /api/v1/factura-cove/status/{task_id} para consultar estado.
"""
def __init__(self) -> None:
self.base_url = settings.COVE_API_URL.strip()
self.verify_ssl = settings.COVE_API_VERIFY_SSL
def generate_cove(self, payload: FacturaCoveRequest) -> CoveExternalResult:
"""
Llama al endpoint externo /api/v1/factura-cove/generar-factura-cove
con el FacturaCoveRequest completo y retorna el resultado tal como
lo reporta el servicio remoto.
"""
url = f"{self.base_url.rstrip('/')}/api/v1/factura-cove/generar-factura-cove"
json_payload = payload.model_dump(mode="json")
configuracion_vu = json_payload.get("configuracion_vu") or {}
logger.info(
"Sending COVE payload: invoice=%s rfc_vu=%s clave_fiel_len=%s clave_ws_len=%s cer_len=%s key_len=%s",
json_payload.get("numero_factura"),
configuracion_vu.get("rfc_usuario_vu"),
len(configuracion_vu.get("clave_fiel") or ""),
len(configuracion_vu.get("clave_webservice") or ""),
len(configuracion_vu.get("archivo_cer_base64") or ""),
len(configuracion_vu.get("archivo_key_base64") or ""),
)
with httpx.Client(timeout=30.0, verify=self.verify_ssl, follow_redirects=False) as client:
resp = client.post(url, json=json_payload)
if resp.status_code in {301, 302, 307, 308} and resp.headers.get("location"):
redirected_url = urljoin(url, resp.headers["location"])
logger.info("COVE generate redirect: %s -> %s", url, redirected_url)
resp = client.post(redirected_url, json=json_payload)
# Intentar parsear JSON siempre, incluso en errores 4xx/5xx
try:
data: Dict[str, Any] = resp.json()
except Exception:
data = {}
# Si el servicio externo respondió con error (por ejemplo 422 Validation Error),
# devolvemos un resultado de error rico en información para que la UI pueda
# mostrar el detalle completo.
if resp.status_code >= 400:
# Intentar construir un mensaje amigable
message = None
if isinstance(data, dict):
message = data.get("message") or data.get("mensaje")
if not message and "detail" in data:
# FastAPI ValidationError-style: detail: [{loc, msg, type}, ...]
try:
parts = [str(d.get("msg")) for d in data["detail"] if isinstance(d, dict)]
message = "; ".join([p for p in parts if p])
except Exception:
pass
if not message and isinstance(data.get("errores"), list):
# Formato API VU: { mensaje, errores: [{ campo, mensaje, tipo_error }] }
parts: list[str] = []
for e in data["errores"]:
if not isinstance(e, dict):
continue
campo = e.get("campo") or ""
msg = e.get("mensaje") or ""
if campo and msg:
parts.append(f"{campo}: {msg}")
elif msg:
parts.append(msg)
if parts:
message = "; ".join(parts)
if not message:
message = resp.text or f"HTTP {resp.status_code}"
status = "validation_error" if resp.status_code == 422 else "error"
return CoveExternalResult(
status=status,
message=message,
cove_number=None,
vucem_operation_num=None,
raw_response={
"status_code": resp.status_code,
"body": data,
},
)
# 2xx: según la especificación del servicio externo, al menos devuelve:
# { "task_id": "...", "status": "...", "message": "..." }
raw_status = str(data.get("status") or "queued")
message = data.get("message")
external_task_id = data.get("task_id")
# Si VU respondió "queued/external_queued/success" pero no task_id, la respuesta
# no es utilizable para polling; tratarlo como error explícito.
if raw_status.lower() in {"queued", "external_queued", "success"} and not external_task_id:
return CoveExternalResult(
status="error",
message=(
"Respuesta inválida del servicio COVE: no se recibió task_id "
"para consultar el estado."
),
cove_number=None,
vucem_operation_num=None,
raw_response={
"status_code": resp.status_code,
"body": data,
},
)
# Normalizar el status de VU a un valor interno que active el polling cuando procede.
# El API de VU ha devuelto distintos valores según la versión:
# - "PENDING" / "STARTED" con task_id → tarea encolada, se debe hacer polling.
# - "error" con mensaje "Factura COVE iniciada para" → mismo caso (versión anterior).
# - cualquier otro valor → se pasa sin modificar.
normalized_status = raw_status.lower()
if normalized_status in {"pending", "started"} and external_task_id:
status = "external_queued"
elif (
normalized_status == "error"
and isinstance(message, str)
and "Factura COVE iniciada para" in message
):
status = "external_queued"
else:
status = raw_status
# El número de COVE normalmente se obtendrá vía /status/{task_id}; por ahora
# lo dejamos en None y exponemos la respuesta completa para inspección en UI.
return CoveExternalResult(
status=status,
message=message,
cove_number=None,
vucem_operation_num=None,
raw_response={
"task_id": external_task_id,
"status": status,
"message": message,
"raw": data,
},
)
def get_status(self, task_id: str) -> Dict[str, Any]:
"""
Consulta el endpoint externo /api/v1/factura-cove/status/{task_id}
y devuelve el JSON de progreso/resultado tal cual lo envía el servicio.
Ejemplo de respuesta esperada (simplificada):
{
"task_id": "...",
"state": "PROGRESS" | "SUCCESS" | "FAILURE",
"result": null | {...},
"error": null | "...",
"progress": {
"current_step": "Consultando respuesta COVE con número de operación",
"progress": 10.5,
"total_steps": 12,
"task_id": "...",
"numero_operacion": "306658625"
}
}
"""
url = f"{self.base_url.rstrip('/')}/api/v1/factura-cove/status/{task_id}"
with httpx.Client(timeout=30.0, verify=self.verify_ssl, follow_redirects=False) as client:
resp = client.get(url)
if resp.status_code in {301, 302, 307, 308} and resp.headers.get("location"):
redirected_url = urljoin(url, resp.headers["location"])
logger.info("COVE status redirect: %s -> %s", url, redirected_url)
resp = client.get(redirected_url)
try:
data: Dict[str, Any] = resp.json()
except Exception:
data = {}
# Adjuntar metadatos mínimos de respuesta HTTP
data.setdefault("status_code", resp.status_code)
return data