214 lines
8.5 KiB
Python
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
|
|
|