From bc50a7d0994e1891cfc3039d0d7e4bacac48fc74 Mon Sep 17 00:00:00 2001 From: marcos Date: Mon, 10 Aug 2026 12:04:12 -0600 Subject: [PATCH] feat(crm): el expediente pide su pedimento provisional a EFC al nacer el folio 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) --- backend/api/v1/modules/crm/cases/service.py | 21 +++++++++++++++++++++ backend/api/v1/modules/crm/router.py | 5 +++++ backend/tests/test_contrato_efc.py | 8 ++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/modules/crm/cases/service.py b/backend/api/v1/modules/crm/cases/service.py index 733c629..db76cc3 100644 --- a/backend/api/v1/modules/crm/cases/service.py +++ b/backend/api/v1/modules/crm/cases/service.py @@ -22,6 +22,27 @@ def create_case( ) db.add(case) db.flush() + + # ── Carril hacia EFC (T2026-08-046) ────────────────────────────────────────────────── + # EFC es la fuente única de los documentos del expediente. El reflejo allá —un pedimento + # provisional— se pide AQUÍ, en el instante en que nace el folio, porque el folio es + # precisamente la llave con la que las dos mitades se reconocen. + # + # El token de almacenamiento se fija al nacer y NO cambia nunca: es la carpeta de MinIO + # del lado de EFC. Que sea inmutable es lo que permite completar el provisional con la + # data aduanera real sin mover un solo archivo. + # + # Todo es best-effort y va en la MISMA transacción que el expediente: + # - con ``EFC_API_URL`` vacía es un no-op y el expediente vive igual, solo en el CRM; + # - si el encolado o el despacho fallan, no se propaga el error: el barrido del beat + # recoge lo pendiente. Un sistema de terceros caído no puede romper un alta. + # El import es diferido para no acoplar el arranque del módulo del expediente al carril. + from ..expediente_gateway import service as gateway + from ..expediente_gateway.storage import storage_token + + case.efc_storage_token = storage_token(company_id, case.reference) + db.flush() + gateway.replicate_expediente_best_effort(db, case) return case diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py index 09353dc..c3c9823 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -17,6 +17,7 @@ from .cases.routes import router as cases_router from .catalogs.routes import router as catalogs_router from .contacts.routes import router as contacts_router from .documents.routes import router as documents_router +from .expediente_gateway.routes import router as expediente_gateway_router from .leads.routes import router as leads_router from .metrics.routes import router as metrics_router from .opportunities.routes import router as opportunities_router @@ -50,3 +51,7 @@ router.include_router(catalogs_router) router.include_router(uploads_router) router.include_router(rates_router) router.include_router(rates_cost_router) +# Tablero de operación del carril hacia EFC: cola pendiente, métricas y reintento manual. +# No es una ruta de negocio; existe para que una persona vea y desatore la entrega sin +# entrar a la base. Hereda el enforcement de crm.access del router agregador. +router.include_router(expediente_gateway_router) diff --git a/backend/tests/test_contrato_efc.py b/backend/tests/test_contrato_efc.py index 6346731..6deddfd 100644 --- a/backend/tests/test_contrato_efc.py +++ b/backend/tests/test_contrato_efc.py @@ -278,10 +278,14 @@ def test_el_formato_del_folio_es_el_del_contrato(db): es contrato, no detalle.""" import re - from api.v1.modules.crm.expedientes.folio import next_folio + # El generador es el del CRM (crm/common/folios.py), no uno del carril: el expediente y su + # consecutivo son del CRM y esta prueba solo afirma que la FORMA que produce es la que EFC + # espera. Por eso se llama a la implementación real y no se reimplementa el formato aquí. + from api.v1.modules.crm.common.folios import next_folio from tests.conftest import COMPANY_ID, TENANT_ID - folio, _, _, _ = next_folio(db, TENANT_ID, COMPANY_ID) + # with_direction=False: el expediente no lleva sufijo I/E, a diferencia de la solicitud. + folio = next_folio(db, TENANT_ID, COMPANY_ID, "EXP", None, with_direction=False) patron = ( API_CRM["folio"]["formato"]