feat(crm): carril emisor hacia EFC sobre el expediente existente (T2026-08-046) #6

Merged
jcedilloAS merged 7 commits from feature/T2026-08-046-carril-efc into main 2026-08-11 14:15:54 +00:00
Member

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 main de EFC (PR #73 y #74 de EFC/backend, más #59 de EFC/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 de
bifurcación del 16 de julio
: 40 commits atrás de esta rama y 0 adelante. Por eso construyó un
expediente paralelocrm.expedientes con 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 EXP no se fusionan.

Se tiró 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 queda reducido a la conexión.

Un detalle que esto evitó: la migración descartada usaba el revision id e6f7a8b9c0d1, el mismo
que crm_catalog_items
de esta rama. Dos migraciones distintas con el mismo id habrían roto
alembic al fusionar.

Qué agrega

  • crm.cases gana seis columnas efc_* y nada más. Son un espejo, nunca el handle: el CRM
    habla de un expediente por su id y su reference, porque del lado de EFC el pedimento_app es
    mutable —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 del
    lado de EFC. Que sea inmutable es lo que permite completar el pedimento sin mover un archivo.
  • El outbox transaccional (crm.efc_sync_outbox y crm.efc_file_outbox) y
    crm/expediente_gateway/: cliente con 3 intentos y backoff lineal que corta en 4xx, worker que
    nunca 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.
  • Las ocho variables EFC_* en config y en el compose de producción, en los tres servicios.
    EFC_API_URL vací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:

  1. El bootstrap de permisos colgaba el rol de un tenant inexistente. /permissions/me creaba
    super_admin con tenant_id = RLS or 1; la sesión de ese request no trae contexto RLS, así que
    caía en 1, que no existe, y el INSERT moría con ForeignKeyViolation. El fallo era silencioso
    hacia afuera: /permissions/me seguía devolviendo 200 con la lista vacía, y en pantalla se
    leí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.
  2. _resolve_tenant_id_for_company estaba sin implementar (pass con un comentario de
    plantilla), así que devolvía None sin contexto RLS. Con esa función se scopean las consultas de
    sus cinco llamadores: la lectura de permisos tampoco resolvía.
  3. El worker de Celery no podía entregar nada. Faltaba registrar las tareas del carril, y
    SQLAlchemy no podía resolver la FK cases.account_id → crm.accounts porque Celery no carga la
    app. El síntoma era cruel: la fila del outbox quedaba en pending con attempts=0 y sin
    error
    —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:

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

Los tres con patente, aduana, clave_pedimento y regimen en None —provisionales de verdad—,
ligados por pedimento_expediente con estado=provisional, y el resolver mapeó el tenant a su
organización por slug. El folio que produce esta rama, EXP2026-08-001, es idéntico al que el
contrato 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 de
subida, 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 que
feature/crm-refinamientos-solicitud-cotizacion y development (afe659e). Esta rama sale
justo de ahí, así que main es 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 10
commits de catálogos SAT de Jair, que hoy no están en main.

Ref: T2026-08-046

🤖 Generated with Claude Code

## 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 `main` de EFC (PR #73 y #74 de `EFC/backend`, más #59 de `EFC/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 de bifurcación del 16 de julio**: 40 commits atrás de esta rama y 0 adelante. Por eso construyó un expediente **paralelo** —`crm.expedientes` con 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 `EXP` no se fusionan. Se tiró 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 queda reducido a la conexión. Un detalle que esto evitó: la migración descartada usaba el revision id `e6f7a8b9c0d1`, **el mismo que `crm_catalog_items`** de esta rama. Dos migraciones distintas con el mismo id habrían roto alembic al fusionar. ## Qué agrega - **`crm.cases` gana seis columnas `efc_*`** y nada más. Son un espejo, nunca el handle: el CRM habla de un expediente por su `id` y su `reference`, porque del lado de EFC el `pedimento_app` es mutable —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 del lado de EFC. Que sea inmutable es lo que permite completar el pedimento **sin mover un archivo**. - **El outbox transaccional** (`crm.efc_sync_outbox` y `crm.efc_file_outbox`) y **`crm/expediente_gateway/`**: cliente con 3 intentos y backoff lineal que corta en 4xx, worker que nunca 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. - **Las ocho variables `EFC_*`** en config y en el compose de producción, en los tres servicios. `EFC_API_URL` vací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: 1. **El bootstrap de permisos colgaba el rol de un tenant inexistente.** `/permissions/me` creaba `super_admin` con `tenant_id = RLS or 1`; la sesión de ese request no trae contexto RLS, así que caía en `1`, que no existe, y el INSERT moría con `ForeignKeyViolation`. El fallo era silencioso hacia afuera: `/permissions/me` seguía devolviendo 200 con la lista **vacía**, y en pantalla se leí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. 2. **`_resolve_tenant_id_for_company` estaba sin implementar** (`pass` con un comentario de plantilla), así que devolvía `None` sin contexto RLS. Con esa función se scopean las consultas de sus **cinco** llamadores: la lectura de permisos tampoco resolvía. 3. **El worker de Celery no podía entregar nada.** Faltaba registrar las tareas del carril, y SQLAlchemy no podía resolver la FK `cases.account_id → crm.accounts` porque Celery no carga la app. El síntoma era cruel: la fila del outbox quedaba en `pending` con `attempts=0` y **sin error** —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: ``` 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 ``` Los tres con `patente`, `aduana`, `clave_pedimento` y `regimen` en `None` —provisionales de verdad—, ligados por `pedimento_expediente` con `estado=provisional`, y el resolver mapeó el tenant a su organización por slug. El folio que produce esta rama, `EXP2026-08-001`, es **idéntico** al que el contrato 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 de subida, 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 que `feature/crm-refinamientos-solicitud-cotizacion` y `development` (`afe659e`). Esta rama sale justo de ahí, así que **`main` es 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 10 commits de catálogos SAT de Jair, que hoy no están en `main`. Ref: T2026-08-046 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mdiaz changed target branch from feature/crm-refinamientos-solicitud-cotizacion to main 2026-08-10 23:02:13 +00:00
mdiaz added 7 commits 2026-08-10 23:02:13 +00:00
El 8000 del host lo ocupa EFC_backend_dev, asi que el backend del stack E2E se
mueve a 3468 (vecino del 3467 de prod). Dentro del contenedor sigue siendo 8000:
gunicorn, healthcheck e INTERNAL_API_URL son de la red interna y no cambian.
El 5173 NO se mueve: es la redirect URI registrada en Workspace.

SUNRISE/, automatizacion/ y docker-compose.dev.yml quedan fuera del control de
versiones: son el corredor de la corrida autonoma y el override de dev de cada
maquina, no configuracion compartida.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
jcedilloAS merged commit 926a75c5f8 into main 2026-08-11 14:15:54 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ADUANASOFT/CRM_AGENTES_CARGA#6
No description provided.