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>
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
"""Contrato del tablero de ops del carril CRM -> EFC.
|
|
|
|
Lo que se fija aquí es lo que el frontend espera recibir: el 404 del reintento sobre una fila que no
|
|
existe (y **no** un 200 silencioso), la forma exacta de la respuesta de éxito, y el aislamiento por
|
|
tenant/company.
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.v1.modules.crm.expediente_gateway import routes
|
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
|
from api.v1.modules.crm.expediente_gateway.models import (
|
|
FILE_KIND_DOCUMENTO,
|
|
SOURCE_CRM_DOCUMENTS,
|
|
STATUS_FAILED,
|
|
STATUS_PENDING,
|
|
STATUS_SENT,
|
|
EfcFileOutbox,
|
|
)
|
|
from api.v1.modules.crm.expedientes import service as expedientes_service
|
|
from api.v1.modules.crm.service_requests import service as sr_service
|
|
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
|
|
|
OTRO_TENANT = 99
|
|
OTRA_COMPANY = 77
|
|
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
|
|
|
|
|
@pytest.fixture()
|
|
def entorno(db, monkeypatch):
|
|
from core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
|
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
|
|
|
solicitud = sr_service.create_service_request(
|
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
|
)
|
|
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
|
return {"db": db, "expediente": expediente}
|
|
|
|
|
|
def _fila_archivo(db, expediente, **kwargs) -> EfcFileOutbox:
|
|
row = EfcFileOutbox(
|
|
kind=FILE_KIND_DOCUMENTO,
|
|
s3_key="k",
|
|
file_name="guia.pdf",
|
|
content_type="application/pdf",
|
|
efc_tipo="MBL",
|
|
source_table=SOURCE_CRM_DOCUMENTS,
|
|
source_id=kwargs.pop("source_id", 1),
|
|
crm_document_ref="CRMDOC-1-1",
|
|
expediente_ref=expediente.id,
|
|
status=kwargs.pop("status", STATUS_PENDING),
|
|
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
|
**kwargs,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def test_retry_de_una_fila_inexistente_da_404_con_mensaje_especifico(entorno):
|
|
"""**Es contrato con el frontend.** Un 200 le haría pintar «reencolado» cuando no hay nada que
|
|
entregar, y el usuario esperaría un badge que nunca va a cambiar."""
|
|
with pytest.raises(HTTPException) as exc:
|
|
routes.retry_outbox(999999, COMPANY_ID, "file", USUARIO, entorno["db"])
|
|
|
|
assert exc.value.status_code == 404
|
|
assert exc.value.detail == "Fila de outbox no encontrada"
|
|
|
|
|
|
def test_retry_exitoso_devuelve_requeued_con_el_id(entorno):
|
|
row = _fila_archivo(entorno["db"], entorno["expediente"], status=STATUS_FAILED)
|
|
|
|
resp = routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"])
|
|
|
|
assert resp == {"status": "requeued", "id": row.id}
|
|
assert row.status == STATUS_PENDING
|
|
|
|
|
|
def test_retry_de_una_fila_de_otra_company_da_404(entorno):
|
|
"""No se filtra la existencia: para ese usuario la fila simplemente no existe."""
|
|
row = _fila_archivo(entorno["db"], entorno["expediente"], company_id=OTRA_COMPANY)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"])
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
def test_metrics_cuenta_por_status(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
_fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED)
|
|
_fila_archivo(db, expediente, source_id=2, status=STATUS_SENT)
|
|
|
|
metricas = routes.metrics(COMPANY_ID, USUARIO, db)
|
|
|
|
assert set(metricas) == {"pending", "sent", "failed"}
|
|
assert metricas["failed"] == 1
|
|
assert metricas["sent"] == 1
|
|
|
|
|
|
def test_metrics_no_ve_otro_tenant(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
_fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED, tenant_id=OTRO_TENANT)
|
|
|
|
assert routes.metrics(COMPANY_ID, USUARIO, db)["failed"] == 0
|
|
|
|
|
|
def test_el_listado_solo_devuelve_lo_del_tenant_y_la_company(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
_fila_archivo(db, expediente, source_id=1)
|
|
_fila_archivo(db, expediente, source_id=2, tenant_id=OTRO_TENANT)
|
|
_fila_archivo(db, expediente, source_id=3, company_id=OTRA_COMPANY)
|
|
|
|
filas = routes.list_outbox(COMPANY_ID, "file", None, 100, USUARIO, db)
|
|
|
|
assert len(filas) == 1
|
|
assert filas[0]["source_id"] == 1
|
|
|
|
|
|
def test_el_listado_filtra_por_status(entorno):
|
|
db, expediente = entorno["db"], entorno["expediente"]
|
|
_fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED)
|
|
_fila_archivo(db, expediente, source_id=2, status=STATUS_SENT)
|
|
|
|
fallidas = routes.list_outbox(COMPANY_ID, "file", STATUS_FAILED, 100, USUARIO, db)
|
|
|
|
assert [f["source_id"] for f in fallidas] == [1]
|