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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user