Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/expedientes/service.py
marcos 02feb973c9 feat(crm): carril con reintentos que entrega los documentos del CRM a EFC
Clon del gateway Anexo22 -> EFC que ya esta en produccion, con los nombres
cambiados. No es una reinterpretacion: la maquina de reintentos de tres capas,
el corte en 4xx y las cuatro guardas de idempotencia se conservan tal cual.

Sin esto, "EFC es la fuente unica" obligaria a llamar a EFC dentro del request
del usuario, y un EFC caido le haria perder su trabajo. Con outbox + Celery, la
subida responde 201 siempre y el sistema entrega cuando EFC vuelve, sin
duplicar: el crm_document_ref viaja con la subida y EFC devuelve 200 con el
documento que ya existia en vez de crear otro. Es lo que cubre el timeout
ambiguo -EFC commiteo y contesto tarde-, donde el CRM no puede saber si entro.

TRES DESVIACIONES DELIBERADAS DEL ORIGINAL, las tres con su razon en el codigo:

1. SAVEPOINT en vez de db.rollback() en el except del encolado. En Anexo22 el
   outbox vive en OTRA base que el pedimento, asi que su rollback solo revertia
   la sesion del outbox. El CRM es mono-base y el encolado corre DENTRO de la
   transaccion del usuario: heredar ese rollback tumbaba la solicitud y el
   expediente recien creados -exactamente lo contrario de best-effort, y en
   silencio-. Lo destapo un test y asi se manifestaba:
   "InvalidRequestError: Instance '<ServiceRequest>' is not persistent within
   this Session". Con el savepoint el fallo deshace solo la fila del outbox.

2. source_table junto a source_id en la guarda _ya_entregado. El CRM tiene DOS
   tablas de documentos con secuencias independientes: crm.documents.id = 5 y
   ops.shipment_documents.id = 5 son documentos distintos. Con el id solo, haber
   entregado el primero haria que el segundo se saltara para siempre sin un solo
   error visible. Hay test que lo fija.

3. EFC_UPLOAD_TIMEOUT_MS aparte de EFC_API_TIMEOUT_MS. Los 8 s de los metadatos
   no alcanzan para un archivo de 25 MB, y el timeout debe quedar POR DEBAJO del
   proxy_read_timeout del nginx de EFC: si el CRM esperara mas, veria un 504
   opaco sin saber si el documento entro.

El cliente HTTP llega con las pruebas que el carril de referencia NO tiene
-verificado: en Anexo22 no hay ni un test de EfcClient._request-, asi que alli
el bucle de reintentos, el backoff y el corte en 4xx nunca se ejercitan. Ese
hueco no se clona: 16 casos contra httpx.MockTransport, sin tocar la red.

Las migraciones se GENERAN y se dejan SIN aplicar.

BLOQUEADO: docker-compose.prod.yml no se toco. El ticket pide las 8 variables en
api, worker y beat, pero Orquestacion.md 13.14 y 4.5 lo prohiben expresamente
("ni tocarlo"), y el orquestador manda sobre el ticket. Queda como paso manual
en el reporte; sin el, worker y beat no ven EFC_API_URL y el carril queda
apagado en produccion, que es degradar limpio y no romper.

Verificacion: pytest tests/ EXIT=0, 151 passed 1 skipped (baseline 70 passed).
Cuatro roturas deliberadas y restauradas: quitando source_table de la guarda el
test de la ambiguedad se puso rojo; reintentando los 4xx los tres tests del
corte dieron "assert 3 == 1"; borrando el objeto local antes de subir cayeron
los tres del corte directo; y disparando el ensure por cualquier 404 se rompio
el test del code.

Ticket: T2026-08-046 (fase 6 de 7)
2026-08-07 18:05:48 -06:00

204 lines
7.2 KiB
Python

"""Servicio del expediente del CRM.
Funciones libres que reciben ``db, tenant_id, company_id``, como el resto de los módulos del repo.
"""
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..expediente_gateway import service as gateway
from ..service_requests.models import ServiceRequest
from .dto import ExpedienteCompleteInput
from .folio import next_folio, storage_token
from .models import Expediente
def _get_service_request(db: Session, service_request_id: int, tenant_id: int, company_id: int) -> ServiceRequest:
obj = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == service_request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
return obj
def get_expediente(db: Session, expediente_id: int, tenant_id: int, company_id: int) -> Expediente:
obj = (
db.query(Expediente)
.filter(
Expediente.id == expediente_id,
Expediente.tenant_id == tenant_id,
Expediente.company_id == company_id,
Expediente.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def list_expedientes(
db: Session,
tenant_id: int,
company_id: int,
service_request_id: int | None = None,
account_id: int | None = None,
exp_status: str | None = None,
) -> list[Expediente]:
query = db.query(Expediente).filter(
Expediente.tenant_id == tenant_id,
Expediente.company_id == company_id,
Expediente.deleted_at.is_(None),
)
if service_request_id is not None:
query = query.filter(Expediente.service_request_id == service_request_id)
if account_id is not None:
query = query.filter(Expediente.account_id == account_id)
if exp_status:
query = query.filter(Expediente.status == exp_status)
return query.order_by(Expediente.created_at.desc()).all()
def find_by_service_request(
db: Session, service_request_id: int, tenant_id: int, company_id: int
) -> Expediente | None:
return (
db.query(Expediente)
.filter(
Expediente.service_request_id == service_request_id,
Expediente.tenant_id == tenant_id,
Expediente.company_id == company_id,
Expediente.deleted_at.is_(None),
)
.first()
)
def ensure_expediente_for_service_request(
db: Session,
service_request_id: int,
tenant_id: int,
company_id: int,
user_id: str | None = None,
account_id: int | None = None,
) -> Expediente:
"""Devuelve el expediente de esa solicitud, creándolo si todavía no existe. **Idempotente.**
**No hace commit**: hace ``flush`` sobre la sesión que recibe. La razón es que sus dos
llamadores —``create_service_request`` y ``create_from_opportunity``— lo invocan ANTES de su
propio commit, dentro de la misma transacción. Si esto commiteara por su cuenta, un fallo
posterior en el alta de la solicitud dejaría un expediente huérfano con su folio ya quemado.
Es la misma disciplina de ``next_folio`` y la del asignador de folios de Anexo22.
"""
existing = find_by_service_request(db, service_request_id, tenant_id, company_id)
if existing is not None:
return existing
folio, year, month, sequence = next_folio(db, tenant_id, company_id)
expediente = Expediente(
folio=folio,
period_year=year,
period_month=month,
sequence=sequence,
service_request_id=service_request_id,
account_id=account_id,
status="abierto",
efc_storage_token=storage_token(company_id, folio),
efc_link_state="PENDING",
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(expediente)
db.flush()
# Réplica hacia EFC: best-effort y en la MISMA transacción. Si EFC está apagado
# (``EFC_API_URL`` vacía) esto es un no-op y el expediente vive igual, solo en el CRM.
gateway.replicate_expediente_best_effort(db, expediente)
return expediente
def ensure_expediente(
db: Session,
service_request_id: int,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Expediente:
"""Variante de cara al usuario: valida la solicitud, asegura el expediente y **sí** commitea.
La usa el endpoint ``POST /expedientes/ensure``, donde la transacción empieza y termina aquí.
"""
solicitud = _get_service_request(db, service_request_id, tenant_id, company_id)
expediente = ensure_expediente_for_service_request(
db, solicitud.id, tenant_id, company_id, user_id, account_id=solicitud.account_id
)
db.commit()
db.refresh(expediente)
return expediente
def _campos_para_efc(campos: dict) -> dict:
"""Traduce los campos del expediente al vocabulario del contrato de EFC.
Las fechas van en ISO porque el payload del outbox se serializa a JSON, y un ``date`` de Python
no es serializable: sin esto la fila se encolaría bien y **fallaría al entregar**, que es el peor
momento para descubrirlo.
"""
salida = {}
for clave, valor in campos.items():
salida[clave] = valor.isoformat() if hasattr(valor, "isoformat") else valor
return salida
def complete_expediente(
db: Session,
expediente_id: int,
payload: ExpedienteCompleteInput,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Expediente:
"""Registra en el CRM la data aduanera real de un expediente.
Solo toca la fila del CRM. Completar el pedimento provisional del lado de EFC es una operación
aparte, del carril del gateway, porque puede fallar por causas de EFC —un pedimento real que ya
existe con esa llave— y eso no debe impedir que el CRM guarde lo que el usuario capturó.
"""
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
if expediente.status == "completado":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="El expediente ya está completado"
)
campos = payload.model_dump(exclude_unset=True)
for field, value in campos.items():
setattr(expediente, field, value)
expediente.status = "completado"
expediente.updated_by = user_id
# El completado del provisional en EFC va por el outbox, no inline: puede devolver 409 si allá
# ya existe ese pedimento real, y eso no debe impedir que el CRM guarde lo que se capturó.
gateway.enqueue_completar_best_effort(db, expediente, _campos_para_efc(campos))
db.commit()
db.refresh(expediente)
return expediente
def delete_expediente(db: Session, expediente_id: int, tenant_id: int, company_id: int) -> None:
"""Baja lógica. No propaga nada a EFC: el expediente electrónico se conserva."""
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
expediente.deleted_at = datetime.now(timezone.utc)
db.commit()