diff --git a/backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py b/backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py index b138c58..868b9b5 100644 --- a/backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py +++ b/backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py @@ -51,6 +51,28 @@ def upgrade() -> None: op.add_column("cases", sa.Column("efc_error_detail", sa.Text(), nullable=True), schema="crm") op.create_index("ix_crm_cases_efc_link_state", "cases", ["efc_link_state"], schema="crm") + # Relleno del token para los expedientes que ya existían. Sin esto, el barrido de + # reconciliación los encola, EFC los rechaza con + # {'storage_token': ['This field may not be null.']} y agotan sus 8 intentos hasta + # quedar en `failed`: ruido permanente por un dato que se podía derivar. + # + # La condición de longitud replica la guarda de storage_token(): en los 25 caracteres de + # `pedimento_app` caben hasta 6 dígitos de company. Lo que no cabe se queda NULL a + # propósito y el encolado lo salta avisando, porque un token recortado apuntaría a la + # carpeta de otro expediente y mezclaría documentos en silencio. + # + # `reference IS NOT NULL` porque el folio es nullable en crm.cases: un expediente sin + # folio no tiene con qué identificarse ante EFC. + op.execute( + """ + UPDATE crm.cases + SET efc_storage_token = 'CRM-' || company_id::text || '-' || reference + WHERE efc_storage_token IS NULL + AND reference IS NOT NULL + AND length('CRM-' || company_id::text || '-' || reference) <= 25 + """ + ) + # ---------- crm.efc_sync_outbox: metadatos (alta del provisional y completado) ---------- op.create_table( "efc_sync_outbox", diff --git a/backend/api/v1/modules/crm/expediente_gateway/service.py b/backend/api/v1/modules/crm/expediente_gateway/service.py index 674697e..e3b8b93 100644 --- a/backend/api/v1/modules/crm/expediente_gateway/service.py +++ b/backend/api/v1/modules/crm/expediente_gateway/service.py @@ -124,6 +124,22 @@ def _enqueue_expediente_outbox(db: Session, expediente: Case) -> Optional[EfcSyn try: if _expediente_ya_encolado(db, expediente.id): return None + # Sin folio o sin token no hay nada que replicar: EFC exige los dos y responde + # {'storage_token': ['This field may not be null.']}, que NO es un fallo transitorio. + # Encolarlo de todos modos quemaría los 8 intentos para acabar en `failed`, ensuciando + # el tablero de ops con algo que ningún reintento puede arreglar. + # + # Pasa de verdad en dos casos: expedientes nacidos antes de que existiera el carril + # (los rellena la migración c5d6e7f8a9b0) y aquellos cuyo token no cabe en los 25 + # caracteres de `pedimento_app`. Se avisa en WARNING porque es una omisión silenciosa: + # el expediente vive en el CRM y sus documentos nunca llegarán a EFC. + if not expediente.reference or not expediente.efc_storage_token: + logger.warning( + "expediente_gateway: expediente id=%s SIN replicar — folio=%r token=%r. " + "No se encola: EFC rechaza ambos nulos y el reintento no lo arregla.", + expediente.id, expediente.reference, expediente.efc_storage_token, + ) + return None # El slug del tenant NO se resuelve aquí: se rellena al ENTREGAR. Resolverlo ahora abriría # una segunda sesión de base (``scoped_core_db``) dentro de la transacción del usuario, que # es justo lo que el encolado debe evitar. Es además lo que hace el carril de referencia. @@ -724,7 +740,15 @@ def find_expediente_gaps(db: Session, limit: int = 200) -> list: ya_encolado = exists().where(EfcSyncOutbox.expediente_ref == Case.id) return ( db.query(Case) - .filter(Case.deleted_at.is_(None), ~ya_encolado) + .filter( + Case.deleted_at.is_(None), + ~ya_encolado, + # Mismo criterio que el encolado: lo que le falta folio o token no es un hueco + # recuperable, es algo que EFC rechazaría siempre. Sin este filtro la + # reconciliación los reencola cada 5 minutos para verlos fallar de nuevo. + Case.reference.isnot(None), + Case.efc_storage_token.isnot(None), + ) .order_by(Case.id.desc()) .limit(limit) .all() diff --git a/backend/api/v1/modules/crm/expediente_gateway/tasks.py b/backend/api/v1/modules/crm/expediente_gateway/tasks.py index 171370d..9573b25 100644 --- a/backend/api/v1/modules/crm/expediente_gateway/tasks.py +++ b/backend/api/v1/modules/crm/expediente_gateway/tasks.py @@ -25,6 +25,27 @@ from core.database import scoped_core_db from . import service from .models import STATUS_PENDING, EfcFileOutbox, EfcSyncOutbox +# ── Registro de modelos: NO son imports decorativos, no los quites ────────────────────── +# El worker de Celery NO carga la app: importa este módulo y sus dependencias, y nada más. +# SQLAlchemy resuelve las ForeignKey por NOMBRE de tabla contra su registro global, así que +# si la clase del otro extremo nunca se importó, la configuración de mappers falla con +# +# Foreign key associated with column 'cases.account_id' could not find table 'crm.accounts' +# +# y la tarea muere con PendingRollbackError. El síntoma es cruel: la fila del outbox se +# queda en `pending` con attempts=0 y SIN last_error —porque el fallo ocurre antes de poder +# registrarlo—, así que el carril se ve encolando bien y no entrega nunca. En la app web no +# pasa: `main.py` monta todos los routers y con ellos se importan todos los modelos. +# +# El juego es el mínimo verificado con `configure_mappers()` en un proceso limpio: +# - accounts : cierra la FK cases.account_id, que es la que rompía; +# - documents y ops.shipments : las dos fuentes del outbox de archivos; +# - tenants : lo consulta el resolver de organización al entregar. +from api.v1.modules.core.tenants import models as _m_tenants # noqa: F401 +from api.v1.modules.crm.accounts import models as _m_accounts # noqa: F401 +from api.v1.modules.crm.documents import models as _m_documents # noqa: F401 +from api.v1.modules.ops.shipments import models as _m_shipments # noqa: F401 + logger = logging.getLogger(__name__)