fix(crm): el worker no podia entregar, y el carril encolaba lo que EFC rechaza siempre

Tres defectos que solo aparecieron al probar con los dos sistemas cableados. Ninguno lo
habrian encontrado las pruebas: usan SQLite con su propio registro de modelos y no
ejercitan el proceso del worker.

1. REGISTRO DE MODELOS EN EL WORKER. Celery no carga la app: importa el modulo de la
   tarea y nada mas. SQLAlchemy resuelve las ForeignKey por nombre de tabla contra su
   registro global, asi que sin la clase del otro extremo importada la configuracion de
   mappers moria con "Foreign key associated with column 'cases.account_id' could not
   find table 'crm.accounts'" y la tarea con PendingRollbackError.
   El sintoma era cruel: la fila del outbox se quedaba en pending con attempts=0 y SIN
   last_error --el fallo ocurre antes de poder registrarlo--, asi que el carril se veia
   encolando bien y no entregaba nunca. En la app web no pasa porque main.py monta todos
   los routers. Se importan los cuatro modelos del juego minimo verificado con
   configure_mappers() en un proceso limpio.

2. LA MIGRACION NO RELLENABA LAS FILAS PREVIAS. Los expedientes creados antes del carril
   quedaban con efc_storage_token NULL; el barrido de reconciliacion los encolaba, EFC los
   rechazaba con {'storage_token': ['This field may not be null.']} y agotaban sus 8
   intentos hasta failed. Ruido permanente por un dato derivable. La migracion ahora
   rellena 'CRM-'||company_id||'-'||reference, con la misma condicion de longitud que la
   guarda de storage_token: lo que no cabe en los 25 de pedimento_app se queda NULL a
   proposito, porque un token recortado apuntaria a la carpeta de otro expediente.

3. SIN GUARDA DE FOLIO NULO. crm.cases.reference es nullable, y ni el encolado ni el
   barrido de huecos lo comprobaban. Ahora los dos saltan lo que no tiene folio o token,
   y el encolado lo avisa en WARNING: es una omision silenciosa --el expediente vive en el
   CRM y sus documentos no llegaran a EFC-- y merece dejar rastro.

Verificado de punta a punta. Los TRES expedientes del CRM estan en EFC, leido desde EFC:

  EXP2026-08-001 -> CRM-2-EXP2026-08-001  provisional
  EXP2026-08-002 -> CRM-2-EXP2026-08-002  provisional
  EXP2026-08-003 -> CRM-2-EXP2026-08-003  provisional

y los tres en LINKED con su outbox en sent. El 001 nacio antes del enganche y se recupero
por el camino del relleno + reintento, que es el que usaria una persona desde el tablero.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:44:12 -06:00
parent be45f950b8
commit 192a2d9d89
3 changed files with 68 additions and 1 deletions

View File

@@ -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",

View File

@@ -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()

View File

@@ -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__)