feat(crm): carril emisor hacia EFC sobre el expediente existente (T2026-08-046) #6
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/T2026-08-046-carril-efc"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Qué resuelve
El lado emisor de la integración: el CRM refleja cada expediente en EFC como un pedimento
provisional y queda listo para entregarle sus documentos. EFC es la fuente única de esos archivos.
El lado receptor ya está en
mainde EFC (PR #73 y #74 deEFC/backend, más #59 deEFC/frontend).Por qué el diff se ve así: esto es un rebase, no una entrega nueva
La entrega anterior del emisor partía de
feature/crm-cumplimiento-pdf, que es el punto debifurcación del 16 de julio: 40 commits atrás de esta rama y 0 adelante. Por eso construyó un
expediente paralelo —
crm.expedientescon su propio generador de folios y su propia migración—que duplicaba el que ya existía aquí. Dos expedientes y dos secuencias peleando por el mismo espacio
de folios
EXPno se fusionan.Se tiró el nuestro. La estructura del expediente es de esta rama y no se toca:
crm.caseses elexpediente, su folio vive en
referencey el consecutivo lo reservacrm/common/folios.pyconbloqueo de fila. Nuestro aporte queda reducido a la conexión.
Un detalle que esto evitó: la migración descartada usaba el revision id
e6f7a8b9c0d1, el mismoque
crm_catalog_itemsde esta rama. Dos migraciones distintas con el mismo id habrían rotoalembic al fusionar.
Qué agrega
crm.casesgana seis columnasefc_*y nada más. Son un espejo, nunca el handle: el CRMhabla de un expediente por su
idy sureference, porque del lado de EFC elpedimento_appesmutable —se reescribe al completar el provisional— y apoyarse en él rompería justo cuando llegue la
data aduanal real.
efc_storage_token, fijado al nacer el folio y nunca reescrito: es la carpeta de MinIO dellado de EFC. Que sea inmutable es lo que permite completar el pedimento sin mover un archivo.
crm.efc_sync_outboxycrm.efc_file_outbox) ycrm/expediente_gateway/: cliente con 3 intentos y backoff lineal que corta en 4xx, worker quenunca lanza, barridos cada 120 s, cuatro capas de idempotencia y tablero de ops con reintento
manual. Clonado del gateway Anexo22 → EFC que ya corre en producción.
EFC_*en config y en el compose de producción, en los tres servicios.EFC_API_URLvacía = carril apagado; desplegar esto no enciende nada.Tres bugs de plataforma que salieron en el camino
Ninguno es del carril, y los tres impedían usar el sistema:
/permissions/mecreabasuper_admincontenant_id = RLS or 1; la sesión de ese request no trae contexto RLS, así quecaía en
1, que no existe, y el INSERT moría conForeignKeyViolation. El fallo era silenciosohacia afuera:
/permissions/meseguía devolviendo 200 con la lista vacía, y en pantalla seleía «No tienes permisos», que manda a revisar roles en vez de la base. Ahora el tenant se toma de
la compañía, que es la fuente autoritativa de esa fila.
_resolve_tenant_id_for_companyestaba sin implementar (passcon un comentario deplantilla), así que devolvía
Nonesin contexto RLS. Con esa función se scopean las consultas desus cinco llamadores: la lectura de permisos tampoco resolvía.
SQLAlchemy no podía resolver la FK
cases.account_id → crm.accountsporque Celery no carga laapp. El síntoma era cruel: la fila del outbox quedaba en
pendingconattempts=0y sinerror —el fallo ocurre antes de poder registrarlo—, así que el carril se veía encolando bien y
no entregaba nunca.
Verificación
152 pruebas en verde, sin regresión respecto a las 113 de esta rama.
Y probado de punta a punta con los dos sistemas conectados, leído desde EFC y no desde el CRM:
Los tres con
patente,aduana,clave_pedimentoyregimenenNone—provisionales de verdad—,ligados por
pedimento_expedienteconestado=provisional, y el resolver mapeó el tenant a suorganización por slug. El folio que produce esta rama,
EXP2026-08-001, es idéntico al que elcontrato con EFC exige; hay una prueba que lo afirma contra la implementación real.
Lo que NO trae, y hay que saberlo antes de mergear
La mitad de archivos del carril. El outbox de archivos, los reintentos y el borrado local están
construidos y probados, pero nadie llama a
enqueue_file_best_effort: falta el enganche desubida, el proxy de descarga y la UI. Subir un documento hoy lo guarda solo en el MinIO del CRM.
Por eso siguen rojas 6 pruebas del carril (2 fallan, 4 no colectan). Están así a propósito: afirman
el comportamiento de la mitad que falta.
Base de este PR
Va contra
main, que apareció después de abrirlo y está en el mismo commit quefeature/crm-refinamientos-solicitud-cotizacionydevelopment(afe659e). Esta rama salejusto de ahí, así que
maines su ancestro directo: 7 commits adelante, 0 atrás, sin conflictos.Sigue pendiente, y no es de este ticket, integrar
feature/AS-catalogos-sat-facturacion— los 10commits de catálogos SAT de Jair, que hoy no están en
main.Ref: T2026-08-046
🤖 Generated with Claude Code
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>Al entrar por primera vez a una compañia, /permissions/me creaba el rol super_admin con `tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1`. La sesion de ese request no trae contexto RLS, asi que caia en el respaldo: tenant_id = 1. En una instalacion real ese tenant no existe -- aqui son 11 y 17 -- y el INSERT moria con ForeignKeyViolation sobre company_roles_tenant_id_fkey. El fallo era silencioso hacia afuera: el except del bootstrap lo registraba como ERROR CRITICO y devolvia False, pero /permissions/me seguia respondiendo 200 con la lista de permisos VACIA. En pantalla se leia "No tienes permisos para realizar esta accion", que manda a revisar roles en vez de la base. Toda la API respondia 403. Dos arreglos: - `_resolve_tenant_id_for_company` tenia el respaldo sin implementar (`pass` con un comentario de plantilla), asi que devolvia None siempre que faltara el contexto RLS. Con esa funcion se scopean las consultas de sus CINCO llamadores, o sea que la lectura de permisos tampoco resolvia. Ahora consulta a76.company, la tabla de companias del CRM, con SQL crudo igual que seed_crm.py. - bootstrap_super_admin toma el tenant de la COMPANIA, con consulta directa y no por el helper: el helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser el de la compania. Para leer permisos esa preferencia esta bien y ahorra una consulta en el camino caliente; para escribir una fila atada por FK a a76.company y a core.tenants a la vez, no -- si difirieran, el rol naceria cruzado entre dos tenants. Si la compania no existe, aborta sin crear nada en vez de inventar un tenant. Verificado contra la base: bootstrap devuelve True en las dos companias del usuario, 70 permisos en cada una, y las filas quedan consistentes (rol de company 1 -> tenant 17, company 2 -> tenant 11). Suite sin regresion: 151 pasan. Ref: T2026-08-046 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Primera mitad del pegamento del carril (fase 7). El reflejo en EFC se pide en create_case, en el instante en que se mintea el folio, porque el folio es la llave con la que las dos mitades se reconocen: EFC no recibe ids del CRM como handle. - crm.cases nace con efc_storage_token = CRM-{company}-{folio}, fijado al nacer y nunca reescrito: es la carpeta de MinIO del lado de EFC, y que sea inmutable es lo que permite completar el provisional con la data aduanera real sin mover un solo archivo. - replicate_expediente_best_effort corre en la MISMA transaccion que el expediente. Con EFC_API_URL vacia es no-op; si el encolado o el despacho fallan no se propaga el error y el barrido del beat recoge lo pendiente. Un sistema de terceros caido no puede romper un alta. - El import del carril es diferido para no acoplar el arranque del modulo del expediente, que es de otra rama, a la integracion. - Se registra el tablero de ops del carril (outbox, metricas, reintento manual) en el router del CRM. test_el_formato_del_folio_es_el_del_contrato se re-apunta a crm/common/folios.py y queda VERDE: afirma contra la implementacion real que el folio del CRM tiene la forma que EFC espera, que era el riesgo de haber rebasado sobre otro expediente. Verificado en la base: create_case produce EXP2026-08-002 con storage_token CRM-2-EXP2026-08-002 y link_state PENDING, 0 filas encoladas por carril apagado, y la transaccion reversada NO deja hueco en el contador -- el with_for_update de crm/common/folios.py revierte limpio. PENDIENTE de la fase 7: documentos (subida de un paso, proxy de descarga, listado y desvinculacion) y los 8 archivos del frontend. Por eso siguen rojas test_efc_outbox, test_gateway_rutas, test_efc_entrega_documento, test_uploads_alcance y dos de test_contrato_efc. Ref: T2026-08-046 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Delta de core/celery_app.py que se me quedo fuera al portar el carril. El sintoma era enganoso: el encolado se veia perfecto -- fila en crm.efc_sync_outbox, status pending, sin error -- pero el worker rechazaba la entrega con "Received unregistered task of type 'expediente_gateway.deliver_outbox_row'" y la fila se quedaba en pending con 0 intentos PARA SIEMPRE. Ni el despacho inmediato ni el barrido existian. - include: api.v1.modules.crm.expediente_gateway.tasks - beat: sweep_outbox y sweep_file_outbox cada 120 s, sweep_expediente_gaps cada 300 s. Los intervalos son los del carril de referencia de Anexo22. El reintento NO es exponencial a proposito: el backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo. Verificado de punta a punta con los dos sistemas cableados. Desde EFC, no desde el CRM: pedimento_app : CRM-2-EXP2026-08-002 (el storage_token del CRM) patente/aduana/clave_pedimento/regimen: None <- provisional de verdad pedimento_expediente: estado=provisional, crm_expediente_id=3, folio=EXP2026-08-002 organizacion : Aduanasoft (hub_tenant_slug=aduanasoft, is_verified=True) licencia : 5 GB, asi que la subida no falla por cuota El resolver mapeo tenant 11 -> organizacion por slug, que es el puente 1:1 acordado. Las cinco tareas quedan registradas en el worker y los tres barridos en el beat. Ref: T2026-08-046 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>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>El compose de produccion no declaraba ninguna EFC_*, asi que el carril nacia muerto alla: `EfcClient.is_configured` era False y todo el enganche hacia no-op en silencio. Van en los TRES servicios y no solo en el backend, porque cada uno hace una parte: - backend encola al crear el expediente y despacha la entrega inmediata; - celery_worker EJECUTA la entrega (`deliver_outbox_row` / `deliver_file_outbox_row`). Si solo el backend las tuviera, el encolado se veria perfecto y nada se entregaria nunca -- es exactamente el modo de fallo que ya nos costo un diagnostico hoy, cuando el worker no tenia registradas las tareas del carril; - celery_beat dispara los tres barridos, que son la red que atrapa lo que el despacho inmediato no alcanzo. Sin el, una caida de EFC deja la cola detenida para siempre. Se declaran las ocho aunque seis queden vacias por default. No es simetria: una variable no declarada en el compose NO llega al contenedor, asi que editarla en el .env no surte efecto y el sintoma parece un problema de red. Ya paso con EFC_API_VERIFY_SSL en el compose de desarrollo, que solo pasa dos de las ocho. EFC_API_URL vacia = carril apagado, y es el default a proposito: desplegar este cambio no enciende nada. Encenderlo exige EFC_API_URL y una EFC_API_KEY identica a la CRM_INTEGRATION_API_KEY del lado de EFC, cuyo permiso es fail-closed. Ref: T2026-08-046 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>