Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.
La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:
- crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
- crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
expediente_ref -> crm.cases.id;
- core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
clonados del gateway Anexo22 -> EFC que ya corre en produccion;
- las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.
Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.
Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.
El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.
PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
14 KiB
Python
302 lines
14 KiB
Python
"""Cliente HTTP hacia EFC (carril de integración CRM Agentes de Carga -> EFC).
|
|
|
|
Clon del cliente del gateway de Anexo22, que es el carril de referencia ya en producción
|
|
(``anexo22/backend/api/v1/modules/pedimentos/pedimento_gateway/client.py``), con las rutas
|
|
cambiadas a ``.../integrations/crm/...``. No es una reinterpretación: el molde de reintentos, el
|
|
corte en 4xx y la forma del error se conservan tal cual.
|
|
|
|
**Síncrono a propósito** (``httpx.Client``): el consumidor es el worker de Celery que drena el
|
|
outbox, que corre en contexto sync. El único punto async del carril es el proxy de descarga de cara
|
|
al usuario, y ése no usa este cliente.
|
|
|
|
Todos los endpoints se autentican con el header ``X-Api-Key``
|
|
(``settings.EFC_API_KEY`` == ``CRM_INTEGRATION_API_KEY`` del lado de EFC).
|
|
"""
|
|
import logging
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from core.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Rutas de EFC (prefijo /api/v1/, ver config/urls.py del backend de EFC).
|
|
_PATH_ORG_BUSCAR = "/api/v1/organization/integrations/crm/organizaciones/"
|
|
_PATH_ORG_RESOLVER = "/api/v1/organization/integrations/crm/organizaciones/resolver/"
|
|
_PATH_EXPEDIENTE = "/api/v1/customs/integrations/crm/expedientes/"
|
|
_PATH_EXPEDIENTE_COMPLETAR = "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/"
|
|
_PATH_EXPEDIENTE_DETALLE = "/api/v1/customs/integrations/crm/expedientes/{folio}/"
|
|
_PATH_DOCS = "/api/v1/record/integrations/crm/documentos/"
|
|
_PATH_DOCS_LIST = "/api/v1/record/integrations/crm/documentos/list/"
|
|
_PATH_DOC_DESCARGAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/"
|
|
_PATH_DOC_ELIMINAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/"
|
|
_PATH_DOC_REEMPLAZAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/"
|
|
|
|
|
|
class EfcClientError(Exception):
|
|
"""Error de comunicación con EFC.
|
|
|
|
``retryable=True`` marca fallos transitorios (5xx/timeout/red) que el worker debe reintentar.
|
|
``status_code``/``code`` exponen la respuesta de EFC para que el worker pueda ramificar
|
|
(p. ej. 404 ``expediente_no_encontrado`` → ensure-then-upload).
|
|
|
|
El worker decide **por el campo ``retryable``**, nunca parseando el mensaje: un texto de error
|
|
cambia con cualquier refactor del otro repo y con él se caería la política de reintentos sin que
|
|
nada se vea roto.
|
|
"""
|
|
|
|
def __init__(self, message: str, status_code: Optional[int] = None,
|
|
code: Optional[str] = None, retryable: bool = False):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.code = code
|
|
self.retryable = retryable
|
|
|
|
|
|
class EfcClient:
|
|
def __init__(
|
|
self,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
timeout_ms: Optional[int] = None,
|
|
upload_timeout_ms: Optional[int] = None,
|
|
verify_ssl: Optional[bool] = None,
|
|
retries: int = 2,
|
|
transport: Optional[httpx.BaseTransport] = None,
|
|
):
|
|
self.base_url = (base_url if base_url is not None else settings.EFC_API_URL).rstrip("/")
|
|
self.api_key = api_key if api_key is not None else settings.EFC_API_KEY
|
|
self.timeout_s = max(0.1, float(timeout_ms or settings.EFC_API_TIMEOUT_MS) / 1000.0)
|
|
# Timeout aparte para las subidas: los 8 s de los metadatos no alcanzan para un archivo de
|
|
# 25 MB. Tiene que quedar POR DEBAJO del proxy_read_timeout del nginx de EFC — si el CRM
|
|
# esperara más, vería un 504 opaco sin saber si el documento entró. Fallando primero de este
|
|
# lado, el reintento con el mismo efc_document_ref es limpio.
|
|
self.upload_timeout_s = max(
|
|
0.1, float(upload_timeout_ms or settings.EFC_UPLOAD_TIMEOUT_MS) / 1000.0
|
|
)
|
|
self.verify_ssl = settings.EFC_API_VERIFY_SSL if verify_ssl is None else verify_ssl
|
|
self.retries = max(0, int(retries))
|
|
self.transport = transport
|
|
# mTLS (scaffolding pre-prod): si hay CA se usa para verificar; si hay par cert/key se
|
|
# presenta como certificado de cliente. Vacío = TLS normal.
|
|
self._ca_path = settings.EFC_MTLS_CA_PATH or ""
|
|
self._cert_path = settings.EFC_MTLS_CERT_PATH or ""
|
|
self._key_path = settings.EFC_MTLS_KEY_PATH or ""
|
|
|
|
def _client_kwargs(self, timeout_s: Optional[float] = None) -> dict:
|
|
"""``verify``/``cert`` para httpx según config mTLS (o TLS normal si no hay mTLS)."""
|
|
verify = self._ca_path if self._ca_path else self.verify_ssl
|
|
kwargs = {
|
|
"timeout": timeout_s or self.timeout_s,
|
|
"verify": verify,
|
|
"transport": self.transport,
|
|
}
|
|
if self._cert_path and self._key_path:
|
|
kwargs["cert"] = (self._cert_path, self._key_path)
|
|
return kwargs
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
"""``False`` = integración deshabilitada (best-effort): sin URL o sin key."""
|
|
return bool(self.base_url and self.api_key)
|
|
|
|
# ── HTTP interno ──────────────────────────────────────────────────────────
|
|
|
|
def _request(self, method: str, path: str, *, json: Any = None,
|
|
params: dict = None, files: dict = None, data: dict = None,
|
|
stream: bool = False, timeout_s: Optional[float] = None):
|
|
if not self.is_configured:
|
|
raise EfcClientError("EFC no configurado (EFC_API_URL/EFC_API_KEY vacíos).", retryable=False)
|
|
|
|
url = f"{self.base_url}{path}"
|
|
headers = {"X-Api-Key": self.api_key}
|
|
last_error: Optional[Exception] = None
|
|
|
|
for attempt in range(self.retries + 1):
|
|
try:
|
|
client = httpx.Client(**self._client_kwargs(timeout_s))
|
|
try:
|
|
response = client.request(method, url, headers=headers, json=json,
|
|
params=params, files=files, data=data)
|
|
except Exception:
|
|
client.close()
|
|
raise
|
|
|
|
if 200 <= response.status_code < 300:
|
|
if stream:
|
|
# El caller lee response.content y cierra el cliente.
|
|
return response, client
|
|
client.close()
|
|
return response
|
|
|
|
# 5xx: transitorio, reintentar.
|
|
if response.status_code >= 500 and attempt < self.retries:
|
|
client.close()
|
|
time.sleep(0.15 * (attempt + 1))
|
|
continue
|
|
|
|
# 4xx u otro: no reintentar. Extraer code/mensaje de EFC.
|
|
code, message = _parse_error_body(response)
|
|
client.close()
|
|
raise EfcClientError(
|
|
message or f"EFC respondió {response.status_code}",
|
|
status_code=response.status_code,
|
|
code=code,
|
|
retryable=response.status_code >= 500,
|
|
)
|
|
|
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
last_error = exc
|
|
if attempt >= self.retries:
|
|
break
|
|
time.sleep(0.15 * (attempt + 1))
|
|
except EfcClientError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 — cualquier fallo inesperado es no-retryable
|
|
raise EfcClientError(str(exc), retryable=False) from exc
|
|
|
|
raise EfcClientError(
|
|
f"EFC inaccesible tras {self.retries + 1} intentos: {last_error}",
|
|
retryable=True,
|
|
)
|
|
|
|
# ── Organización ──────────────────────────────────────────────────────────
|
|
|
|
def buscar_organizaciones(self, q: str) -> list:
|
|
"""Búsqueda por texto, para el alta manual desde una pantalla de administración."""
|
|
return self._request("GET", _PATH_ORG_BUSCAR, params={"q": q}).json()
|
|
|
|
def resolve_organizacion(self, tenant_slug: str, tenant_name: Optional[str] = None) -> dict:
|
|
payload = {"tenant_slug": tenant_slug}
|
|
if tenant_name:
|
|
payload["tenant_name"] = tenant_name
|
|
return self._request("POST", _PATH_ORG_RESOLVER, json=payload).json()
|
|
|
|
# ── Expediente (pedimento provisional en EFC) ──────────────────────────────
|
|
|
|
def ingest_expediente(self, payload: dict) -> dict:
|
|
"""Crea el pedimento provisional del expediente. 201 si es nuevo, 200 si ya existía."""
|
|
return self._request("POST", _PATH_EXPEDIENTE, json=payload).json()
|
|
|
|
def completar_expediente(self, folio: str, payload: dict) -> dict:
|
|
"""Completa un provisional con la data aduanera real. Ningún archivo se mueve."""
|
|
path = _PATH_EXPEDIENTE_COMPLETAR.format(folio=folio)
|
|
return self._request("POST", path, json=payload).json()
|
|
|
|
def get_expediente(self, folio: str, organizacion_id: str) -> dict:
|
|
path = _PATH_EXPEDIENTE_DETALLE.format(folio=folio)
|
|
return self._request("GET", path, params={"organizacion_id": str(organizacion_id)}).json()
|
|
|
|
# ── Documentos ────────────────────────────────────────────────────────────
|
|
|
|
def upload_documento(self, organizacion_id: str, crm_company_id: int, crm_expediente_id: int,
|
|
tipo: str, filename: str, content: bytes,
|
|
content_type: str = "application/octet-stream",
|
|
crm_document_ref: Optional[str] = None) -> dict:
|
|
"""Sube un documento al expediente. Multipart, nunca base64.
|
|
|
|
``crm_document_ref`` es **el handle de NUESTRO registro de origen**, y es lo que después
|
|
permite recuperar el archivo sin guardar de este lado ningún identificador de EFC. EFC lo
|
|
guarda junto al documento con un UNIQUE parcial por ``(organizacion, ref)``, así que una
|
|
entrega repetida devuelve el documento que ya existía (200) en vez de crear otro (201).
|
|
|
|
Es la cuarta capa de idempotencia del carril y la única que garantiza la base: la entrega la
|
|
hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde—
|
|
duplicaría el documento sin esto.
|
|
"""
|
|
files = {"file": (filename, content, content_type)}
|
|
data = {
|
|
"organizacion_id": str(organizacion_id),
|
|
"crm_company_id": str(int(crm_company_id)),
|
|
"crm_expediente_id": str(int(crm_expediente_id)),
|
|
"tipo": tipo,
|
|
}
|
|
if crm_document_ref:
|
|
data["crm_document_ref"] = crm_document_ref
|
|
return self._request(
|
|
"POST", _PATH_DOCS, files=files, data=data, timeout_s=self.upload_timeout_s
|
|
).json()
|
|
|
|
def list_documentos(self, organizacion_id: str, crm_expediente_id: int, *,
|
|
tipo: Optional[str] = None,
|
|
crm_document_ref: Optional[str] = None) -> list:
|
|
"""Documentos del expediente. ``tipo`` y ``crm_document_ref`` son filtros OPCIONALES.
|
|
|
|
Preguntar por ``crm_document_ref`` es lo que permite recuperar ``efc_document_id`` si el
|
|
cache local se perdió, sin guardar identificadores ajenos como handle.
|
|
"""
|
|
params = {
|
|
"organizacion_id": str(organizacion_id),
|
|
"crm_expediente_id": str(int(crm_expediente_id)),
|
|
}
|
|
if tipo:
|
|
params["tipo"] = tipo
|
|
if crm_document_ref:
|
|
params["crm_document_ref"] = crm_document_ref
|
|
return self._request("GET", _PATH_DOCS_LIST, params=params).json()
|
|
|
|
def replace_documento(self, organizacion_id: str, doc_id: str, filename: str, content: bytes,
|
|
content_type: str = "application/octet-stream") -> dict:
|
|
"""Sustituye el CONTENIDO de un documento conservando su fila (mismo id, mismo tipo).
|
|
|
|
EFC sube primero y borra el viejo al final, así que una subida fallida deja el anterior
|
|
intacto y descargable.
|
|
"""
|
|
files = {"file": (filename, content, content_type)}
|
|
data = {"organizacion_id": str(organizacion_id)}
|
|
path = _PATH_DOC_REEMPLAZAR.format(doc_id=doc_id)
|
|
return self._request(
|
|
"PUT", path, files=files, data=data, timeout_s=self.upload_timeout_s
|
|
).json()
|
|
|
|
def download_documento(self, organizacion_id: str, doc_id: str) -> tuple[bytes, str]:
|
|
path = _PATH_DOC_DESCARGAR.format(doc_id=doc_id)
|
|
params = {"organizacion_id": str(organizacion_id)}
|
|
response, client = self._request("GET", path, params=params, stream=True)
|
|
try:
|
|
content = response.content
|
|
filename = _filename_from_response(response, default=str(doc_id))
|
|
finally:
|
|
client.close()
|
|
return content, filename
|
|
|
|
def download_url(self, doc_id: str) -> str:
|
|
"""URL absoluta del endpoint de descarga de EFC, para el proxy async de la fase 7.
|
|
|
|
El proxy no puede usar ``download_documento``: éste es síncrono y bufferiza el archivo
|
|
entero. Lo que necesita es la URL y el header, y hace su propio streaming.
|
|
"""
|
|
return f"{self.base_url}{_PATH_DOC_DESCARGAR.format(doc_id=doc_id)}"
|
|
|
|
@property
|
|
def auth_headers(self) -> dict:
|
|
"""El header de autenticación, para el proxy async que no pasa por ``_request``."""
|
|
return {"X-Api-Key": self.api_key}
|
|
|
|
|
|
def _parse_error_body(response) -> tuple[Optional[str], Optional[str]]:
|
|
"""Extrae ``(code, message)`` del cuerpo de error estructurado de EFC
|
|
(``{"error": {"code", "message"}}``) sin reventar si no es JSON."""
|
|
try:
|
|
body = response.json()
|
|
except Exception:
|
|
return None, None
|
|
err = body.get("error") if isinstance(body, dict) else None
|
|
if isinstance(err, dict):
|
|
return err.get("code"), err.get("message")
|
|
return None, None
|
|
|
|
|
|
def _filename_from_response(response, default: str) -> str:
|
|
disp = response.headers.get("Content-Disposition", "")
|
|
if "filename=" in disp:
|
|
return disp.split("filename=")[-1].strip().strip('"') or default
|
|
return default
|
|
|
|
|
|
# Instancia por defecto (lee settings). Los tests inyectan su propio transport construyendo
|
|
# EfcClient(transport=httpx.MockTransport(...)).
|
|
efc_client = EfcClient()
|