Files
CRM_AGENTES_CARGA/docs/planes/T2026-08-046_prompt.md
marcos 59fb371f5d docs(crm): abre el changelog del repo y versiona el plan de T2026-08-046
El CRM no tenia CHANGELOG.md. Se crea con el formato del de EFC para que un ticket
que cruza los dos productos se lea igual de los dos lados, y se abre con la entrada
de T2026-08-046 (fases 5-7).

Va en un PR aparte del codigo a proposito: el changelog es el unico archivo
garantizado en colision entre tickets, y metido en cada PR de codigo convierte cada
merge en una resolucion de conflictos en vez de una revision.

La entrada dice tambien lo que NO esta: el lado de EFC del carril todavia no aterriza,
las variables de entorno de produccion quedaron sin tocar y el contrato entre repos
esta afirmado solo desde el CRM. Un changelog que afirma un entregable completo cuando
esta a medias es peor que ninguno.

Se versiona ademas el documento Prompt del ticket en docs/planes/, siguiendo la
convencion de EFC, para que las decisiones del codigo tengan su porque a la vista sin
depender de una carpeta de corrida. Un dato se sustituyo al versionar: un caso de
prueba traia un token con forma de llave de pedimento real y aqui va como dummy; el
caso prueba que esa FORMA se rechace, asi que el numero concreto no aportaba nada.

Refs: T2026-08-046
2026-08-10 07:50:41 -06:00

85 KiB
Raw Blame History

Documento Prompt versionado — T2026-08-046.

Es el plan con el que trabajó la corrida SUNRISE del 2026-08-07, tal como estaba al arrancar. Se versiona para que las decisiones del código tengan su porqué a la vista sin depender de una carpeta de corrida.

Dos avisos para quien lo lea:

  1. Está completo, pero esta corrida solo ejecutó las fases 5, 6 y 7 (las del repo del CRM). Las fases 1-4 son del backend de EFC y corren con su propio orquestador, desde EFC/SUNRISE/2026-08-07/. Al cerrar esta corrida todavía no estaban.
  2. Un dato se sustituyó al versionar. El caso 3 de la tabla de pruebas de EFC traía un storage_token con forma de llave de pedimento real; aquí va como 00-00-0000-0000000. El caso prueba que un token con forma de pedimento real se rechace, así que el número concreto no aporta nada y no tiene por qué quedar versionado.

Lo que la corrida acabó decidiendo, y en qué se apartó de este plan, está en SUNRISE/2026-08-07/Diario-2026-08-07.md y en el reporte de la misma carpeta.


T2026-08-046 — Integración CRM Agentes de Carga ↔ EFC: expedientes y documentos

Plan de implementación asincrónica (documento Prompt del ticket). Productos: CRM Agentes de Carga + EFC 2.0 · Repos: CRM CRM_AGENTES_CARGA (backend y frontend) + EFC backend · Prioridad: NORMAL · Tipo: NUEVO REQUERIMIENTO · Rama: feature/T2026-08-046 en cada repo

Un solo ticket. Cruza dos productos con repos separados, así que lleva una rama y un PR por repo (Orquestacion.md §1: la unidad de trabajo es el ticket; el número de ramas es Σ tickets × repos que toca). El trabajo va en siete fases con dependencias duras — §7 manda el orden.

Nota de ejecución. EFC/SUNRISE/Orquestacion.md solo opera sobre backend, frontend y microservice de EFC. La copia de este ticket en EFC/SUNRISE/2026-08-07/T2026-08-046.md cubre las fases 1-4 y corre con ese orquestador. Las fases 5-7 son del repo del CRM y necesitan otra vía: la ventana nocturna de SUNRISE/VENTANA-NOCTURNA-AUTONOMA.md de este repo, o ejecución interactiva.


1. Contexto

Problema

El CRM de agentes de carga genera documentos —constancia fiscal, MBL/HBL/MAWB, factura comercial, packing list, PDF de factura— que hoy viven en su propio MinIO: sin expediente que los agrupe, sin catálogo validado en backend, y con un delete_document que hace soft delete y nunca borra el objeto (backend/api/v1/modules/crm/documents/service.py, función delete_document). EFC es el sistema de expedientes electrónicos de la casa y debe ser la fuente única de esos archivos.

El obstáculo estructural. En EFC el expediente es el Pedimento, y record.Document.pedimento es FK NOT NULL con llave de negocio pedimento_app = AA-XX-PPPP-NNNNNNN. El CRM no tiene ni un dato de pedimento: verificado que no existe ningún campo de aduana, patente, número ni año en los modelos de crm/ ni de ops/; ops.shipments solo tiene customs_agent_id (FK a crm.suppliers).

Y el CRM no tiene entidad expediente ni generador de folio. Verificado: las tres apariciones de "expediente" son herencia de plantilla —core/s3_keys.py tiene expediente_archivo_document_key y expediente_archivo_artifact_key con cero llamadores, y alembic/versions/9db46c604463_initial_schema.py crea la tabla huérfana a76.expediente_archivo sin modelo ni ruta—. reference en crm.service_requests, crm.quotes, ops.shipments y fin.invoices es String(40) nullable sin server_default, sin trigger y sin código que lo genere.

Qué se entrega

Un expediente en el CRM con folio EXP2026-08-001 generado al crear una solicitud, un pedimento provisional en EFC por cada uno, y un carril máquina-a-máquina que sube los documentos del CRM al expediente de EFC con reintentos, borrando la copia local al confirmar. Cuando llega la data aduanera real, el provisional se completa sin mover un solo archivo.

Resultado esperado

El usuario adjunta un MBL desde la ficha del embarque y lo ve aparecer con badge En expediente. Si EFC está caído, lo ve como Pendiente de enviar en vez de perder su trabajo, y el sistema lo entrega solo cuando EFC vuelve, sin duplicarlo. Un operador de EFC abre el expediente y ve el documento con fuente = APP-CRM.

Por qué el enlace va en una tabla desechable

Anexo22 es el sistema centralizado de pedimentos y EFC ya es su espejo (Pedimento.anexo22_pedimento_id). Está planeado que el CRM se conecte a Anexo22, y ese día los embarques del CRM se ligarán directo al pedimento de Anexo22. Por eso el acoplamiento vive todo en una tabla nueva (pedimento_expediente) y no se agrega ni una columna a pedimento: la tabla se dropea el día de la unión y no queda nada que migrar.


2. Decisiones cerradas — no re-abrir durante la implementación

Tema Decisión Por qué
Dónde vive el enlace Tabla nueva pedimento_expediente en EFC. Cero columnas nuevas en pedimento La tabla se dropea cuando el CRM se una a Anexo22; una columna obligaría a otra migración
Handle del CRM hacia EFC folio + crm_expediente_id. Nunca pedimento_id ni pedimento_app pedimento_app es mutable: se reescribe al completar. Mismo principio que el gateway de Anexo22, que "no guarda referencias de EFC"
Llave del provisional storage_token = CRM-{company_id}-EXP{YYYY}-{MM}-{NNN} Empieza con letras → imposible colisionar con ^\d{2}-\d{2}-\d{4}-\d{7}$. Y el company_id evita que dos companies del mismo tenant generen el mismo valor
Carpeta de MinIO Se pasa siempre storage_token a save_document(), no pedimento.pedimento_app El token es inmutable → ningún objeto se mueve nunca al completar
Escrituras sobre Pedimento .update() de queryset, siempre Ver H1: save() cuesta un sleep(4) síncrono
Autenticación del carril Clon literal de HasAnexo22IntegrationApiKey con secreto propio CRM_INTEGRATION_API_KEY Consistencia con el carril de referencia. La deuda (key sin binding a organización) se documenta, no se cierra aquí
Modo de entrega Outbox + Celery, sin HTTP inline en el request Es como funciona el gateway de Anexo22, y es lo que se pidió: "guarda temporal y reintenta en cola"
"Fuente única" Vía delete_local: el objeto local se borra al confirmar la entrega "Solo EFC" es el estado final (eventual), no el inmediato
Borrado en el CRM Desasocia, no destruye en EFC El gateway de Anexo22 nunca llama al DELETE de EFC. record.Document no tiene vigencia ni purga → la política implícita es conservar
Tipos de documento Conjunto cerrado validado en ambos lados En el CRM doc_type es texto libre sin validación de backend; un typo crearía basura en el catálogo global de EFC
Colisión al completar 409, no se fusiona Es dato fiscal: un fallo visible es mejor que colgar documentos del pedimento equivocado
Ancla del expediente La solicitud (crm.service_requests) Un expediente por hilo comercial, de RFQ a factura, siguiendo la cadena que el CRM ya tiene
Descarga Proxy con streaming en el CRM EFC nunca entrega URL de MinIO: reescribir el host de una URL firmada invalida SigV4

Nomenclatura fija — se usa literal en todas las fases

Fuente en EFC .................. "APP-CRM"
storage_token .................. CRM-{company_id}-EXP{YYYY}-{MM}-{NNN}   ej. CRM-1-EXP2026-08-001
folio del CRM .................. EXP{YYYY}-{MM}-{NNN}                    ej. EXP2026-08-001
Pedimento.pedimento ............ el folio, sin prefijo                   ej. EXP2026-08-001
crm_document_ref ............... {TABLA}-{company_id}-{row_id}           ej. SHPDOC-1-4471, CRMDOC-1-903
                                 LEGACY-{tabla}-{id} para el backfill
                                 INVOICE-{company_id}-{invoice_id} para el PDF de factura
Prefijo de tipos en EFC ........ "CRM - "        ej. "CRM - MBL (Master Bill of Lading)"

Datos dummy — nunca reales

RFC XAXX010101000, pedimento 0000-0000000, patente 0000, aduana 000.


3. El carril de referencia: Anexo22 → EFC

Directriz. El carril CRM → EFC se construye clonando el carril Anexo22 → EFC que ya está en producción, con los nombres cambiados. No es una reinterpretación.

Ruta: C:\Users\USUARIO\Desktop\anexo 22\anexo22\backend\api\v1\modules\pedimentos\pedimento_gateway\

Archivo Líneas Qué se calca
client.py 269 EfcClient, EfcClientError(status_code, code, retryable), reintentos, backoff, corte en 4xx, mTLS, is_configured, transport inyectable
models.py 124 Las dos tablas de outbox, los estados, MAX_ATTEMPTS, delete_local
service.py ~1670 Enganche best-effort, entrega, _register_failure, _ya_entregado, ensure-then-upload, ops, reconciliación de huecos
tasks.py 171 Entrega inmediata + barridos + reconciliación
routes.py 52 Tablero de ops: listar, reintentar, métricas

Y el patrón de folio concurrente-seguro: catalogos/customs_brokers/folios.py + pedimentos/folio_validacion.py del mismo repo.

La máquina de reintentos, en sus tres capas — esto es lo que se clona

Capa 1, en el cliente HTTP. retries = 2 → 3 intentos. Backoff lineal time.sleep(0.15 * (attempt + 1)). 2xx éxito · >=500 con intentos restantes reintenta · 4xx NO reintenta y extrae code/message del cuerpo estructurado de EFC · TimeoutException/NetworkError reintenta y al agotarse lanza retryable=True · cualquier otra excepción retryable=False. El error lleva retryable para que el worker decida por el campo y nunca parseando strings, y code para ramificar.

Capa 2, en el worker.

def _register_failure(db, row, exc, retryable):
    row.attempts = (row.attempts or 0) + 1
    row.last_error = str(exc)[:2000]
    if (not retryable) or row.attempts >= MAX_ATTEMPTS:
        row.status = STATUS_FAILED
    db.commit()

deliver_row nunca lanza: captura EfcClientError y Exception y registra el fallo en la propia fila. Un fallo no mata al worker ni pierde la intención.

Capa 3, barridos de Celery beat. Cada 120 s para las dos tablas de outbox, 300 s para la reconciliación de huecos. Leen pending (limit 100, created_at ASC) sin contexto de tenant y re-despachan una tarea hija por fila con sus headers RLS.

Backoff de intervalo fijo, no exponencial. No hay autoretry_for, retry_backoff ni max_retries en las tareas: el reintento es el bucle de 0.15/0.30 s del cliente más el barrido de 120 s, hasta MAX_ATTEMPTS = 8.

Cuatro guardas de idempotencia. (1) _ya_entregado(source_table, source_id, kind) antes de encolar — EXISTS(status='sent'); su docstring en el original explica el defecto: sin ella, un reintento encolaba otra entrega que además falla al leer el objeto local porque la primera ya lo borró. (2) if row.status == STATUS_SENT: return al entrar a entregar. (3) Preguntar a EFC por crm_document_ref antes de subir. (4) El UniqueConstraint parcial del lado de EFC: una entrega repetida devuelve el documento que ya existía (200) en vez de crear otro (201).

Ensure-then-upload. Si el upload devuelve 404 expediente_no_encontrado, se crea el provisional y se reintenta el upload una vez. Resuelve la carrera entre la creación del expediente y la subida.

Corte directo (delete_local). Al confirmar, borra el objeto de MinIO local, envuelto en su propio try porque "ya está en EFC".

Best-effort en todo el enganche. if not settings.EFC_API_URL: return en cada punto de entrada; el encolado en try/except con rollback() y return None para que la integración nunca afecte la operación local; el despacho también best-effort — "si el broker no responde, el sweep la recoge".


4. Hallazgos verificados — las tres primeras corrompen datos fiscales

H1 — .update() obligatorio, nunca save(). En EFC/backend/api/customs/signals/procesamiento.py, el receptor trigger_celery_task_on_update tiene como única condición if not created:cualquier save() sobre un Pedimento ejecuta un sleep(4) síncrono y encola procesamiento espurio. Anexo22PedimentoIngestView (buscar pedimento_obj.save() en api/customs/views_integrations_anexo22.py) sí lo hace. Es la única parte de Anexo22 que NO se clona.

H2 — trampa del rango VU 13-26. DocumentType.objects.get_or_create() deja al autoincrement asignar el id; si cae en 13-26, Document.save() marca vu=True (buscar 13 <= en api/record/models.py) y el documento entra en los borrados masivos de VU (api/record/views.py, acciones bulk-delete-partidas-vu, bulk-delete-coves-vu, bulk-delete-edocs-vu) → pérdida de archivo fiscal. Usar siempre _resolver_document_type de api/record/views_integrations_anexo22.py, que fuerza max(max(id)+1, 27) y avanza la secuencia con setval(). get_or_create para DocumentType está prohibido.

H3 — el provisional SÍ recibe checklist. En EFC/backend/core/cumplimiento_documental.py, buscar generales.get(org_id): sin clave_pedimento ni tipo_operacion_id la resolución cae al checklist TipoChecklist.TODOS. Si la organización tiene uno configurado, cada provisional publica un porcentaje bajo real en las 8 columnas cumplimiento_* de Pedimento — el modo de fallo que el autor del motor documentó y quiso evitar (buscar contamina reportes).

H4 — Anexo22 no propaga bajas a EFC hoy, pero Document.pedimento es CASCADE. El campo operacion se declara en Anexo22PedimentoIngestSerializer y nunca se lee en la vista; el cliente de Anexo22 no tiene método DELETE. Cuando esa propagación se construya, borrar un pedimento borraría los documentos del CRM en silencio. Por eso el enlace va con on_delete=PROTECT.

H5 — colisión de folio entre companies. El puente es Organizacion.hub_tenant_slugTenant.slug, o sea tenant → organización 1:1, pero un tenant tiene N companies. Dos companies generando EXP2026-08-001 colisionarían en unique_together(organizacion, pedimento_app) y el get_or_create devolvería el provisional de la company A a la B. Por eso el company_id entra en el storage_token.


5. Contrato

5.1 EFC — carril máquina-a-máquina

Header obligatorio en todas: X-Api-Key: <CRM_INTEGRATION_API_KEY>.

GET    /api/v1/organization/integrations/crm/organizaciones/?q=texto
POST   /api/v1/organization/integrations/crm/organizaciones/resolver/
POST   /api/v1/customs/integrations/crm/expedientes/
POST   /api/v1/customs/integrations/crm/expedientes/{folio}/completar/
GET    /api/v1/customs/integrations/crm/expedientes/{folio}/
POST   /api/v1/record/integrations/crm/documentos/
GET    /api/v1/record/integrations/crm/documentos/list/
GET    /api/v1/record/integrations/crm/documentos/{uuid:pk}/descargar/
DELETE /api/v1/record/integrations/crm/documentos/{uuid:pk}/eliminar/
PUT    /api/v1/record/integrations/crm/documentos/{uuid:pk}/reemplazar/

Resolver organización — body A: {"tenant_slug": "temex", "tenant_name": "TEMEX"}; body B (alta manual): {"nombre": "...", "rfc": "XAXX010101000"}. Respuesta 200/201: {"id","nombre","rfc","hub_tenant_slug","is_active","created"}.

Crear expediente provisional — body:

{"crm_tenant_slug": "temex", "crm_company_id": 1, "crm_expediente_id": 42,
 "folio": "EXP2026-08-001", "storage_token": "CRM-1-EXP2026-08-001"}

201 (nuevo) / 200 (ya existía):

{"status": "created", "vucem": "no_aplica",
 "efc": {"pedimento_id": "<uuid>", "pedimento_app": "CRM-1-EXP2026-08-001",
         "storage_token": "CRM-1-EXP2026-08-001", "estado": "provisional",
         "consultar_vucem": false, "existe_expediente": false}}

Completar — body: {"crm_expediente_id": 42, "pedimento": {"numero_pedimento": "0000001", "aduana": "000", "patente": "0000", "anio": 2026, "clave_documento": "A1", "tipo_operacion": "IMP", "regimen": "IMD", "fecha_inicio": "...", "fecha_final": "...", "fecha_pago": "..."}, "importador": {"rfc": "XAXX010101000", "nombre_razon_social": "..."}, "agente_aduanal": {"rfc_agente": "...", "curp_apoderado": "..."}}. 200: {"status":"completed","efc":{"pedimento_id","pedimento_app","pedimento_app_anterior","consultar_vucem":false}}

Subir documentoparser_classes = [MultiPartParser], solo multipart, no base64. form-data: crm_company_id, crm_expediente_id, tipo, crm_document_ref, y file como archivo. 201 (nuevo) / 200 (idempotente) con _crm_doc_to_dict = _doc_to_dict + crm_document_ref.

Formato de error uniforme: {"error": {"code": "...", "message": "..."}}

Código error.code Cuándo
400 payload_invalido serializer inválido
400 pedimento_app_reservado el storage_token matchea ^\d{2}-\d{2}-\d{4}-\d{7}$
400 storage_token_invalido más de 25 caracteres
400 efc_pedimento_app_incompleto al completar falta aduana/patente/número/año
400 tipo_invalido, archivo_faltante, extension_no_permitida, archivo_demasiado_grande validación de subida
400 espacio_insuficiente cuota de licencia agotada
403 documento_no_eliminable la fuente del documento no es APP-CRM
403 (sin cuerpo) API key ausente, vacía o incorrecta
404 expediente_no_encontrado dispara el ensure-then-upload del cliente
404 documento_no_encontrado incluye el caso cross-organización: 404, no 403
409 organizacion_no_utilizable is_active/is_verified en False
409 licencia_sin_espacio licencia en 0 GB
409 expediente_ya_completado el pedimento_app ya no empieza con CRM-
409 pedimento_real_ya_existe + {pedimento_id, pedimento_app, total_documentos} del existente
409 conflicto carrera perdida en el IntegrityError
502 error_storage save_document devolvió falsy

5.2 CRM — API de usuario

GET  /api/v1/crm/expedientes?company_id=1
GET  /api/v1/crm/expedientes/{id}?company_id=1
POST /api/v1/crm/expedientes/ensure?company_id=1
POST /api/v1/crm/expedientes/{expediente_id}/documentos?company_id=1
     multipart: file · form: doc_type, name?, target?, target_owner_id?
GET  /api/v1/crm/expedientes/{expediente_id}/documentos/{document_id}/archivo?company_id=1
GET  /api/v1/crm/expediente-gateway/outbox?tipo=&status=&limit=&company_id=1
POST /api/v1/crm/expediente-gateway/outbox/{outbox_id}/retry?company_id=1
GET  /api/v1/crm/expediente-gateway/metrics?company_id=1

Todos con company_id: int = Query(...) obligatorio y current_user: dict = Depends(get_current_user), como el resto del CRM.

POST documentos → 201 con {expediente_id, folio, document_id, doc_type, name, content_type, size_bytes, efc_sync_state, efc_document_ref}. Sin file_key ni file_url. 404 si el expediente no existe en ese tenant/company; 422 por doc_type, extensión o tamaño.

GET archivoStreamingResponse con el Content-Type y el Content-Disposition de EFC. 404 si el documento no pertenece a ese tenant/company; 502 si EFC falla, salvo un 404 de EFC que pasa como 404.

retry{"status":"requeued","id":<id>}; si la fila no existe para ese tenant/company → 404 con mensaje específico, no 200: es contrato con el frontend. metrics{"pending","sent","failed"}.

Formato del folio: EXP{YYYY}-{MM}-{NNN}, consecutivo por (tenant, company, mes), que reinicia cada mes. Al pasar de 999 crece a 4 dígitos.


6. Fases

Cada fase es un bloque coherente y verificable. Las dependencias son de código, no de reloj.

# Repo Alcance Depende de
1 EFC backend Credencial X-Api-Key + resolver de organización
2 EFC backend Tabla pedimento_expediente + provisional + completado 1
3 EFC backend Endpoints de documentos + crm_document_ref 1, 2
4 EFC backend Anti-contaminación: cumplimiento + grid 2
5 CRM backend crm.expedientes + generador de folio
6 CRM backend crm/expediente_gateway — clon del gateway de Anexo22 5
7 CRM back+front Subida de un paso, proxy de descarga, UI 6

Si la fase 1 no queda mergeable, las fases 2, 3 y 4 no se empiezan y se registran como no ejecutadas con el motivo. Si la 2 no queda mergeable, tampoco la 3 ni la 4. La 4 no depende de la 3. Las fases 5-7 son de otro repo y no dependen de las de EFC para compilar: el gateway se desarrolla contra httpx.MockTransport sin EFC arriba. La 7 sí necesita la 6.

Colisión de archivos entre fases (para resolver el conflicto conservando los dos bloques al mergear): las fases 1 y 3 tocan EFC/backend/config/settings.py; las fases 2 y 4 usan EFC/backend/api/customs/models_crm.py; las fases 5, 6 y 7 tocan CRM/backend/tests/conftest.py y api/v1/modules/crm/router.py. Las fases 3 y 4 no comparten un solo archivo.


7. Compuertas previas — sin estas tres verdes, el carril no se habilita

No son código: son verificaciones que van al reporte.

  1. Licencia. Licencia.almacenamiento en GB de la organización EFC destino. La licencia "Hub SSO Default" tiene 0 GB (api/organization/views_integrations.py, buscar el get_or_create de la licencia) → Document.save() lanza ValueError y falla el 100% de las subidas.
  2. Visibilidad. Organizacion.is_active y is_verified en True. Con is_verified=False los cuatro mixins de backend/mixins/filtrado_organizacion.py (buscar is_verified) hacen la organización invisible incluso para superusuarios.
  3. Tamaño. Hoy hay tres números desalineados: CRM 25 MB (crm/uploads/routes.py, MAX_UPLOAD_BYTES), nginx de producción client_max_body_size 100M (fuera de git), y Document.size es PositiveIntegerField (~2.1 GB). Elegir uno y aplicarlo en los tres lugares.

8. Fase 1 — EFC: credencial del carril y resolver de organización

8.1 Archivos nuevos

api/organization/views_integrations_crm.pyCrmOrganizacionSearchView (GET) y CrmOrganizacionResolverView (POST). Ambas con authentication_classes = [] y permission_classes = [HasCrmIntegrationApiKey].

Helper _asegurar_organizacion_visible_crm(org): fuerza is_verified=True y una licencia con almacenamiento > 0. NO toca apply_auto_download ni credenciales_desde_mve — el CRM no transporta e.firma y sus provisionales van con consultar_vucem=False.

Se clona views_integrations_anexo22.py de la misma app, no el de MVE: _asegurar_organizacion_visible_mve enciende además apply_auto_download=True y credenciales_desde_mve=True, que en el CRM no aplican.

_org_to_dict y _ensure_efc_organization se importan (de organization/views_integrations.py y cuser/sso_views.py), no se reescriben.

8.2 Archivos a modificar

Archivo Qué cambia Ancla de texto
core/permissions.py Añadir HasCrmIntegrationApiKey class HasAnexo22IntegrationApiKey — insertar después de esa clase completa
config/settings.py CRM_INTEGRATION_API_KEY = os.getenv('CRM_INTEGRATION_API_KEY', '') ANEXO22_INTEGRATION_API_KEY
.env.example (backend y raíz) Declarar CRM_INTEGRATION_API_KEY= vacía ANEXO22_INTEGRATION_API_KEY
api/organization/urls.py 2 paths explícitos, nunca en el router integrations/anexo22/organizaciones
class HasCrmIntegrationApiKey(permissions.BasePermission):
    """Autenticación del carril CRM Agentes de Carga -> EFC (expedientes provisionales + documentos).

    Carril propio con su propio secreto: no se reusa el de MVE ni el de Anexo22 porque una key
    compartida hace imposible revocar un solo consumidor.

    INTERINA, igual que las de MVE y Anexo22: la key NO está ligada a una organización — el
    organizacion_id lo elige el llamador, así que una key da acceso a TODAS las organizaciones.
    Endurecer con require_permission('integration.crm_replicate') + mTLS terminado en nginx antes de
    producción. El scaffolding de mTLS ya existe del lado del cliente (EFC_MTLS_*), así que activarlo
    es configuración, no código.
    """
    message = 'API key inválida o no configurada.'

    def has_permission(self, request, view):
        from django.conf import settings
        expected = getattr(settings, 'CRM_INTEGRATION_API_KEY', '') or ''
        if not expected:
            # Fail-closed: sin secreto configurado no se atiende a nadie.
            return False
        provided = request.headers.get('X-Api-Key', '') or ''
        return hmac.compare_digest(provided, expected)

Migración: no agrega migración.


9. Fase 2 — EFC: tabla de enlace, provisional y completado

9.1 Archivos nuevos

api/customs/models_crm.py — precedente de archivo de modelos aparte: api/record/models_descargas.py.

class PedimentoExpediente(models.Model):
    """Enlace TEMPORAL entre un expediente del CRM de agentes de carga y su pedimento en EFC.

    ESTA TABLA ESTÁ DESTINADA A DESAPARECER. Existe solo mientras el CRM no esté conectado a
    Anexo22 —el sistema centralizado de pedimentos del que EFC ya es espejo vía
    Pedimento.anexo22_pedimento_id—. Cuando esa unión ocurra, Anexo22 creará el pedimento real, EFC lo
    replicará por su carril, este enlace repuntará `pedimento` y finalmente la tabla se dropea. Por eso
    el acoplamiento vive AQUÍ y no como columnas en `pedimento`: una columna en `pedimento` obligaría a
    otra migración el día del retiro.

    `pedimento` va con PROTECT y no con CASCADE: `record.Document.pedimento` es CASCADE, así que borrar
    el pedimento borraría los documentos que el CRM guardó. Hoy Anexo22 no propaga bajas a EFC (el campo
    `operacion` de su serializer de ingesta se declara y nunca se lee), pero cuando esa propagación se
    construya, un borrado silencioso se llevaría archivo fiscal. PROTECT hace que falle ruidosamente y
    obliga a repuntar o soltar el enlace primero, que es el orden correcto.

    `storage_token` es INMUTABLE: es la carpeta de MinIO. El carril del CRM lo pasa a
    storage_service.save_document() en lugar de `pedimento.pedimento_app` —que sí cambia al completar—,
    de modo que todos los objetos de un expediente viven bajo un solo prefijo para siempre y ninguno
    necesita moverse nunca.
    """
    pedimento = models.ForeignKey('customs.Pedimento', on_delete=models.PROTECT,
                                  related_name='enlace_crm')
    organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE,
                                     related_name='enlaces_crm')
    crm_expediente_id = models.IntegerField()
    folio = models.CharField(max_length=20)
    storage_token = models.CharField(max_length=25)
    crm_tenant_slug = models.CharField(max_length=100, blank=True, default='')
    crm_company_id = models.IntegerField()
    estado = models.CharField(max_length=20, default='provisional')  # provisional | completado
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        db_table = 'pedimento_expediente'
        constraints = [
            models.UniqueConstraint(fields=['organizacion', 'crm_company_id', 'crm_expediente_id'],
                                    name='uq_crm_link_org_company_exp'),
            models.UniqueConstraint(fields=['organizacion', 'folio'], name='uq_crm_link_org_folio'),
            models.UniqueConstraint(fields=['pedimento'], name='uq_crm_link_pedimento'),
        ]

api/customs/views_integrations_crm.pyCrmExpedienteProvisionalView, CrmExpedienteCompletarView, CrmExpedienteDetalleView.

Se importan de api/customs/views_integrations.py, no se reescriben: _err, _construir_pedimento_app, _ImportadorDataSerializer, _AgenteAduanalDataSerializer, _PedimentoDataSerializer.

_construir_pedimento_app se importa. Tiene tres implementaciones en la casa que deben coincidir (ésta, la del alta manual en api/customs/views.py, y la de api/datastage/tasks.py). Reimplementarla aquí sería la cuarta.

fecha_pago no existe en _PedimentoDataSerializer; se agrega por subclase para no tocar el serializer que usan MVE y Anexo22:

class _PedimentoCrmDataSerializer(_PedimentoDataSerializer):
    fecha_pago = serializers.DateField(required=False, allow_null=True)

Guarda de reserva, antes de cualquier escritura:

_RE_PEDIMENTO_REAL = re.compile(r'^\d{2}-\d{2}-\d{4}-\d{7}$')

# El carril del CRM NO PUEDE crear ni pisar un pedimento real por ningún bug. La llave real es todo
# dígitos con tres guiones; el provisional empieza con letras. Se rechaza explícitamente en vez de
# confiar en que el CRM mande bien el prefijo.
if _RE_PEDIMENTO_REAL.match(storage_token):
    return _err("pedimento_app_reservado", "...", status.HTTP_400_BAD_REQUEST)

Creación del provisional:

with transaction.atomic():
    pedimento_obj, created = Pedimento.objects.get_or_create(
        organizacion=organizacion,
        pedimento_app=storage_token,
        defaults={
            "pedimento": folio[:20],
            "numero_operacion": folio[:20],
            # OBLIGATORIO en False: trigger_celery_task_on_create
            # (api/customs/signals/procesamiento.py) retorna temprano si no está en True, así que
            # crear el provisional no dispara el pipeline VUCEM. El CRM no manda pedimentos a la
            # Ventanilla; EFC solo resguarda sus documentos.
            "consultar_vucem": False,
            # Cinturón: si algún save() futuro sobre esta fila disparara
            # trigger_celery_task_on_update, con estos dos en cero no encola remesa ni partida.
            "remesas": False,
            "numero_partidas": 0,
        },
    )
    PedimentoExpediente.objects.get_or_create(
        organizacion=organizacion, crm_company_id=..., crm_expediente_id=...,
        defaults={"pedimento": pedimento_obj, "folio": folio,
                  "storage_token": storage_token, "crm_tenant_slug": ...,
                  "estado": "provisional"},
    )

Si created es False, NO se hace pedimento_obj.save(). Cualquier ajuste va con Pedimento.objects.filter(pk=...).update(...).

Completado:

def _completar_pedimento_provisional(enlace, *, campos: dict, pedimento_app_nuevo: str):
    """Completa un provisional del carril CRM con la data aduanera real.

    Escribe SIEMPRE con .update() de queryset y NUNCA con .save(): el receptor
    trigger_celery_task_on_update (api/customs/signals/procesamiento.py) tiene como única condición
    `if not created:`, así que un save() ejecuta un sleep(4) SÍNCRONO dentro del request y encola
    procesamiento espurio. .update() no dispara señales.

    Los documentos ya subidos NO se tocan: la FK Document.pedimento no cambia. Y los objetos de MinIO
    NO se mueven, porque el carril del CRM guarda bajo `storage_token`, que es inmutable, y no bajo
    `pedimento_app`, que es lo que acaba de cambiar. Mover objetos exigiría una API de copia que
    storage_service no tiene, y un fallo a medias dejaría filas de Document apuntando a keys
    inexistentes: peor que un prefijo viejo.

    Devuelve (ok, codigo_error).
    """
    try:
        with transaction.atomic():
            Pedimento.objects.filter(pk=enlace.pedimento_id).update(
                pedimento_app=pedimento_app_nuevo, **campos)
            enlace.estado = 'completado'
            enlace.save(update_fields=['estado', 'updated_at'])
    except IntegrityError:
        # unique_together (organizacion, pedimento_app): ese pedimento REAL ya existe en EFC
        # (datastage/MVE/Anexo22 lo sembraron). Mover los documentos del provisional al real es una
        # fusión de datos fiscales y NO se hace en silencio.
        return False, "pedimento_real_ya_existe"
    return True, None

Después del .update(): llamar _recalcular_cumplimiento(enlace.pedimento_id) (importado de api/record/views.py) envuelto en try/except. Es necesario aquí: el checklist aplicable depende de clave_pedimento y tipo_operacion, que acaban de aparecer, así que la proyección cumplimiento_* quedó obsoleta.

Bitácora igual que Anexo22 (registrar_actividad / construir_cambios) dentro de try/except: pass con el comentario "la bitácora nunca debe interrumpir la ingesta".

9.2 Archivos a modificar

Archivo Qué cambia Ancla de texto
api/customs/models.py Import de models_crm para que Django registre el modelo fin del archivo
api/customs/urls.py 3 paths integrations/crm/expedientes/... integrations/anexo22/pedimento

Migración: agrega migración de customs. Se genera con python manage.py makemigrations customs y se deja sin aplicar. Nombre esperado 0027_pedimentoexpediente. Verificar el número real con ls api/customs/migrations/, no asumirlo, y comprobar con makemigrations --check --dry-run que quede una sola hoja.


10. Fase 3 — EFC: endpoints de documentos

10.1 Archivos nuevos

api/record/views_integrations_crm.py — cinco vistas + _crm_doc_to_dict + _resolver_expediente + FUENTE_CRM + TIPOS_DOCUMENTO_CRM.

Se importan, no se copian:

from api.record.views_integrations import _doc_to_dict, _err, _resolver_pedimento
from api.record.views_integrations_anexo22 import _resolver_document_type
from api.record.views import _asignar_tipo_expediente, _recalcular_cumplimiento

_doc_to_dict no se modifica. Su propio comentario advierte que es el punto de serialización de dos integraciones que viven en otros repos y que los cambios deben ser aditivos. Se define _crm_doc_to_dict(doc) que lo llama y agrega crm_document_ref.

FUENTE_CRM = "APP-CRM"

# Conjunto CERRADO, a diferencia del carril Anexo22, que manda el tipo libre y deja que EFC lo resuelva
# por nombre. Ahí funciona porque sus 9 tipos son constantes de código; en el CRM `doc_type` es
# String(60)/String(30) SIN validación de backend —los catálogos viven solo en TypeScript— así que un
# typo crearía un DocumentType basura en el catálogo GLOBAL de EFC. El prefijo "CRM - " garantiza además
# que _resolver_document_type nunca choque con un nombre de MVE o de Anexo22, y que el id caiga en >= 27.
TIPOS_DOCUMENTO_CRM = {
    # --- crm.documents (DOC_TYPES de frontend/src/lib/api/crm/format.ts) ---
    "constancia_fiscal":     ("CRM - Constancia de Situación Fiscal", "Documento de cliente/proveedor del CRM"),
    "acta_constitutiva":     ("CRM - Acta Constitutiva", "..."),
    "identificacion":        ("CRM - Identificación Oficial", "..."),
    "comprobante_domicilio": ("CRM - Comprobante de Domicilio", "..."),
    "contrato":              ("CRM - Contrato", "..."),
    "presentacion":          ("CRM - Presentación Comercial", "..."),
    "certificacion":         ("CRM - Certificación", "..."),
    "licencia":              ("CRM - Licencia", "..."),
    "convenio":              ("CRM - Convenio", "..."),
    "tarifario":             ("CRM - Tarifario", "..."),
    # --- ops.shipment_documents (SHIPMENT_DOC_TYPES del mismo archivo) ---
    "MBL":  ("CRM - MBL (Master Bill of Lading)", "Documento de transporte del embarque"),
    "HBL":  ("CRM - HBL (House Bill of Lading)", "..."),
    "MAWB": ("CRM - MAWB (Master Air Waybill)", "..."),
    "HAWB": ("CRM - HAWB (House Air Waybill)", "..."),
    "CMR":  ("CRM - CMR (Carta Porte Internacional)", "..."),
    "factura_comercial":   ("CRM - Factura Comercial", "..."),
    "packing_list":        ("CRM - Packing List", "..."),
    "carta_encomienda":    ("CRM - Carta Encomienda", "..."),
    "carta_garantia":      ("CRM - Carta Garantía", "..."),
    "certificado_permiso": ("CRM - Certificado / Permiso", "..."),
    # --- fin.invoices ---
    "factura_venta": ("CRM - Factura de venta (PDF)", "PDF de factura generado por el CRM"),
    # --- 'otro' existe en AMBAS listas del CRM y significa lo mismo: una sola entrada ---
    "otro": ("CRM - Otro", "Documento sin tipo específico enviado desde el CRM"),
}

Orden no negociable del POST — las doce reglas de EFC, en el orden en que se aplican:

  1. Validar tipo contra TIPOS_DOCUMENTO_CRM → 400 tipo_invalido. Validar extensión contra allowlist (.pdf .xml .png .jpg .jpeg .json .txt .zip .docx .xlsx) y file.size contra CRM_MAX_UPLOAD_BYTES. EFC hoy no tiene ni allowlist ni tope propio; el único freno real es la cuota de licencia.
  2. _resolver_expediente(organizacion, crm_company_id, crm_expediente_id) → 404 expediente_no_encontrado.
  3. _resolver_document_type(nombre, descripcion)nunca get_or_create (H2).
  4. Fuente.objects.get_or_create(nombre=FUENTE_CRM, ...).
  5. Idempotencia ANTES de save_document(): Document.objects.filter(organizacion=org, crm_document_ref=ref).first() → 200 con ese documento. Comprobarlo aquí y no después es lo que evita el objeto huérfano en MinIO y que la cuota se cobre dos veces.
  6. Cuota: UsoAlmacenamiento.objects.get_or_create(...); uso.espacio_utilizado + file.size > organizacion.licencia.almacenamiento * 1024**3 → 400 espacio_insuficiente.
  7. storage_service.save_document(file=file, organizacion_id=org.id, pedimento_app=enlace.storage_token, metadata={"source": "crm", "crm_document_ref": ref}). Falsy → 502 error_storage.

    Se pasa enlace.storage_token, NO pedimento.pedimento_app. Es la única diferencia real con Anexo22 en esta llamada, y es lo que hace que ningún objeto tenga que moverse al completar.

  8. Document.objects.create(..., nombre_original=file.name, size=file.size, extension=..., crm_document_ref=ref), envuelto en:
    • except ValueErrorstorage_service.delete_file(ruta) + 400 espacio_insuficiente. Document.save() revalida la cuota en su propia transacción y lanza ValueError; sin limpiar la ruta queda un objeto huérfano cobrando espacio.
    • except IntegrityErrordelete_file(ruta), releer el que ganó la carrera → 200, o 409 conflicto.
  9. _asignar_tipo_expediente(documento, organizacion.id). Devolverá False para los tipos del CRM (el mapa solo cubre los 7 legacy de sistema); se llama por consistencia y para no divergir si el mapa crece.

    _asignar_tipo_por_nomenclatura NO se llama — el carril Anexo22 tampoco lo hace. Si algún día se llama, recibe file.name y nunca la ruta guardada: el uuid4().hex[:8] que inyecta _generate_filename puede contener por azar una nomenclatura corta de letras hex.

  10. _recalcular_cumplimiento(enlace.pedimento_id) en try/except — "el recálculo es una consecuencia de la operación, no parte de ella".
  11. 201 con _crm_doc_to_dict.

DELETE: exige documento.fuente.nombre == FUENTE_CRM → si no, 403 documento_no_eliminable. Verifica que el documento pertenezca al pedimento del enlace. Captura la ruta y el pedimento_id antes del delete() (no hay post_delete sobre Document, a propósito), luego delete_file y _recalcular_cumplimiento. Cross-organización → 404, no 403: no filtrar existencia.

PUT reemplazar: clon fiel de Anexo22DocumentoReemplazarView, con las tres cosas que su código documenta:

  • Cuota por delta: uso - (documento.size or 0) + file.size > max_bytes (Document.save() solo cobra la diferencia).
  • Subir el nuevo ANTES de borrar el viejo. El precedente de api/record/views.py borra antes de subir y eso pierde archivo fiscal si la subida falla.
  • Preservar vu, partida_id, cove_id, edocument_id con un .update() posterior si save() los movió: Document.save() recalcula vu y re-resuelve las FKs de sub-entidad por heurística de nombre vía resolver_fk.

GET descargar: streaming, con minio_client.abrir_objeto() y release_conn() en finally, siguiendo api/record/views_descargas.py.

No clonar el NamedTemporaryFile(delete=False) + atexit.register(unlink) de Anexo22DocumentoDescargarView: atexit corre al terminar el proceso, y bajo un worker de vida larga los temporales se acumulan hasta llenar disco.

No se clona Anexo22DocumentoAsignarIdView (el PATCH .../anexo22-id/): era una pasada de relleno histórica y el CRM no tiene nada que rellenar.

10.2 Archivos a modificar

Archivo Qué cambia Ancla de texto
api/record/models.py Campo crm_document_ref + UniqueConstraint parcial en Meta.constraints anexo22_document_id para el campo; document_anexo22_id_tipo_uniq para el constraint
api/record/urls.py 5 paths integrations/crm/documentos/... integrations/anexo22/documentos
config/settings.py CRM_MAX_UPLOAD_BYTES, CRM_ALLOWED_EXTENSIONS CRM_INTEGRATION_API_KEY (lo dejó la fase 1)
crm_document_ref = models.CharField(
    max_length=64, null=True, blank=True, db_index=False,
    help_text="Handle del documento de origen en el CRM de agentes de carga",
)
# Es TEXTO y no un entero como anexo22_document_id porque el CRM tiene DOS tablas de documentos con
# secuencias independientes —crm.documents y ops.shipment_documents— más fin.invoices: un solo entero
# colisionaría entre ellas (crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten).
# Formato: {TABLA}-{company_id}-{row_id}.
#
# Es la CUARTA capa de idempotencia del carril, y la única que la base garantiza: la entrega del CRM la
# hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde— duplicaría
# el documento sin esto.
#
# db_index=False EXPLÍCITO: la tabla ronda los 5M de renglones. El índice único lo crea una migración
# aparte con CREATE INDEX CONCURRENTLY.
models.UniqueConstraint(
    fields=['organizacion', 'crm_document_ref'],
    condition=models.Q(crm_document_ref__isnull=False),
    name='document_crm_ref_uniq',
),
# Parcial como uq_organizacion_hub_tenant_slug_nonblank: las filas que no vienen del CRM comparten NULL
# y no deben colisionar entre sí. Sobre (organizacion, ref) y no sobre la ref sola porque toda lectura
# de este carril ya filtra por organizacion.

Migración: agrega dos migraciones de record, ambas generadas y sin aplicar:

  1. 0015_document_crm_ref — solo el AddField con db_index=False y sin el constraint. En PostgreSQL ≥ 11 un ADD COLUMN nullable sin default volátil es metadata-only: no reescribe los 5M de renglones.
  2. 0016_document_crm_ref_indexatomic = False, con SeparateDatabaseAndState: state_operations=[AddConstraint(...)] y database_operations=[RunSQL('CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "document_crm_ref_uniq" ON "document" ("organizacion_id", "crm_document_ref") WHERE "crm_document_ref" IS NOT NULL;', reverse_sql=...)].

Precedente exacto a leer antes de escribirla: api/record/migrations/0004_document_subentidad_fk.py y 0005_document_subentidad_idx.py, incluido el IF NOT EXISTS para que el reintento sea idempotente y la nota de recuperación si un build queda INVALID.

No clonar 0008_document_anexo22_document_id.py: hizo el campo con db_index=True y el AddConstraint en la misma migración bloqueante sobre la tabla de 5M.

El reporte debe decir que la 0016 hay que medirla en dev antes de producción y que aplicarla es ventana de mantenimiento humana, no trabajo de la corrida.


11. Fase 4 — EFC: anti-contaminación de los provisionales

Todo derivado de pedimento_expediente, sin columnas nuevas en Pedimento. Cuando la tabla se dropee, estos tres cambios se retiran y no queda migración pendiente.

from django.db.models import Exists, OuterRef
from api.customs.models_crm import PedimentoExpediente

_ES_PROVISIONAL = Exists(
    PedimentoExpediente.objects.filter(pedimento=OuterRef('pk'), estado='provisional')
)
Archivo Qué cambia Ancla de texto
api/customs/cumplimiento.py Si el pedimento es provisional, dejar las 8 columnas cumplimiento_* en NULL y salir la función que hace el .update() de las columnas cumplimiento_
api/customs/views.py ViewSetPedimento.get_queryset(): anotar y excluir provisionales salvo ?incluir_provisionales=true class ViewSetPedimento → su get_queryset
api/customs/views.py PedimentoFilter.Meta.fields += es_provisional, crm_expediente_folio class PedimentoFilter
api/customs/serializers.py PedimentoSerializer += los dos campos derivados (aditivo) class PedimentoSerializer

Justificación para el docstring de la guarda de cumplimiento:

Un expediente provisional del CRM no tiene clave_pedimento ni tipo_operacion, así que no hay checklist
ADUANAL aplicable: publicar un porcentaje sería medir el vacío. porcentaje=None ya es el contrato del
motor para "nada aplicable" (core/cumplimiento_documental.py), así que todo lo de aguas abajo es
null-safe. Sin esta guarda el provisional cae al checklist TipoChecklist.TODOS de la organización y
emite un porcentaje bajo REAL que arrastra los reportes.

Un solo cambio cubre cuatro endpoints: verificado que list, export_excel, activo_fijo y export_excel_activo_fijo todos entran por self.get_queryset(). Importa porque PedimentoPagination.page_size es None y paginate_queryset devuelve None sin page_size, así que /pedimentos/ devuelve TODOS los pedimentos.

NO aplicar la exclusión a DescargaExpedienteViewSet ni a los tres bulk-delete-*-vu: reciben listas explícitas de ids y filtrar ahí sería incorrecto.

Los dos campos nuevos van fuera de ordering_fields y de search_fields, igual que las columnas cumplimiento_*.

Migración: no agrega migración.


12. Fase 5 — CRM: entidad expediente y generador de folio

12.1 Archivos nuevos

Módulo api/v1/modules/crm/expedientes/ con la convención rígida del repo: __init__.py, models.py, dto.py (Pydantic v2, Base/Create/Update/Response, ConfigDict(from_attributes=True)), service.py (funciones libres que reciben db, tenant_id, company_id), routes.py (APIRouter sin prefijo propio), folio.py, doc_types.py.

crm.expedientes (Base, TenantScopedMixin, TimestampMixin):

Campo Tipo Nota
id Integer PK
folio String(20) NOT NULL EXP2026-08-001
period_year / period_month / sequence Integer NOT NULL folio descompuesto → el contador es un constraint real, no un parse de string
service_request_id FK crm.service_requests.id nullable index el ancla
account_id FK crm.accounts.id nullable
status String(20) NOT NULL server_default 'abierto' abierto | completado | cerrado
efc_organizacion_id / efc_pedimento_id String(36) nullable espejo, no handle
efc_storage_token String(25) nullable inmutable una vez asignado
efc_link_state String(20) NOT NULL server_default 'PENDING' PENDING | LINKED | FAILED
efc_error_code String(60) / efc_error_detail Text nullable diagnóstico visible en la ficha, sin ir a logs
patente, aduana, numero_pedimento, anio, clave_pedimento, regimen, fecha_pago, rfc_importador, rfc_agente_aduanal data aduanera real, se llena al completar
created_by / updated_by String(64)

Constraints: uq_crm_expedientes_folio UNIQUE (tenant_id, company_id, folio) y uq_crm_expedientes_periodo_seq UNIQUE (tenant_id, company_id, period_year, period_month, sequence).

Es la red de seguridad: si el contador se corrompe, un folio duplicado falla ruidosamente en vez de mezclar dos expedientes.

class ExpedienteFolioCounter(Base)expediente_folio_counters, schema crm, PK compuesta (tenant_id, company_id, period) con period String(7) ("2026-08"), y last_seq Integer NOT NULL default 0.

folio.py:

def next_folio(db: Session, tenant_id: int, company_id: int, on: date | None = None) -> tuple[str, int, int, int]:
    """Reserva el siguiente consecutivo del mes y devuelve (folio, year, month, sequence).

    Una sola sentencia atómica, sin read-modify-write: el INSERT ... ON CONFLICT DO UPDATE serializa
    sobre la fila de ese (tenant, company, mes) y devuelve el valor ya incrementado. Un
    `SELECT max(sequence)+1` es exactamente la carrera que hay que evitar, y un SELECT ... FOR UPDATE
    también sirve pero son dos viajes.

    NO hace commit: opera sobre la sesión que recibe, para que un fallo posterior en la creación del
    expediente pueda hacer rollback sin quemar el folio. Es la misma disciplina del asignador de folios
    de Anexo22 (catalogos/customs_brokers/folios.py): validar antes de tocar el contador, no commitear
    dentro del asignador, y fallar cerrado ante ambigüedad.

    Un rollback deja HUECO en la secuencia. Los huecos son aceptables; los duplicados no.
    """
INSERT INTO crm.expediente_folio_counters (tenant_id, company_id, period, last_seq)
VALUES (:t, :c, :p, 1)
ON CONFLICT (tenant_id, company_id, period)
DO UPDATE SET last_seq = crm.expediente_folio_counters.last_seq + 1
RETURNING last_seq

service.pyensure_expediente_for_service_request(db, service_request_id, tenant_id, company_id, user_id=None) -> Expediente (idempotente), get_expediente, list_expedientes, complete_expediente.

doc_types.pyEFC_DOC_TYPES: frozenset[str] con exactamente las 22 claves de TIPOS_DOCUMENTO_CRM de la fase 3.

12.2 Archivos a modificar

Archivo Qué cambia Ancla de texto
api/v1/common/base_models.py Añadir EfcDocumentRefMixin class TenantScopedMixin — insertar después
api/v1/modules/crm/documents/models.py Aplicar el mixin a Document class Document(Base, TenantScopedMixin, TimestampMixin)
api/v1/modules/ops/shipments/models.py Aplicar el mixin a ShipmentDocument class ShipmentDocument
api/v1/modules/crm/service_requests/service.py create_service_request y create_from_opportunity llaman a ensure_expediente_for_service_request en la misma transacción, antes del db.commit() def create_service_request y def create_from_opportunity
api/v1/modules/crm/router.py include_router del módulo nuevo el include_router de documents
api/v1/modules/crm/permissions.py Añadir la entidad expediente la lista de entidades
tests/conftest.py import api.v1.modules.crm.expedientes.models # noqa los imports de modelos existentes

Sin la línea del conftest.py las tablas nuevas no entran a Base.metadata y los tests fallan de forma opaca.

EfcDocumentRefMixin — una sola definición para no duplicar diez columnas en dos tablas: expediente_id, efc_document_ref, efc_document_id, efc_sync_state, efc_synced_at, efc_error_code, efc_error_detail, efc_attempts, content_sha256.

"""Columnas del espejo de un documento en EFC. Se aplica a crm.documents y a ops.shipment_documents.

`efc_document_ref` es el handle AUTORITATIVO —el CRM lo construye y EFC lo guarda—; `efc_document_id`
es solo un CACHE de la resolución, recuperable por el endpoint de lista si se pierde. Es el mismo
principio que aplica el gateway de Anexo22: el sistema de origen conserva el registro de SU dato, y con
eso pide el archivo de vuelta, en lugar de guardar identificadores ajenos en columnas propias.

`efc_sync_state` es el estado del ESPEJO (lo que pinta la UI: badge, botón reintentar). La cola de
trabajo vive aparte, en crm.efc_file_outbox. No son redundantes: el outbox es indexable por
next_attempt_at y sobrevive a un borrado cuya fila ya no está.
"""

Migración: agrega migración de Alembic. down_revision = el head real, verificado con alembic heads, no asumido. Estilo idéntico a alembic/versions/d5e6f7a8b9c0_pdf_compliance.py: op.create_table(..., schema="crm"), op.add_column(..., schema=...), op.create_foreign_key(..., source_schema=, referent_schema=), y downgrade() completo en orden inverso.

backend/main.py corre alembic upgrade head al arrancar, así que en este repo no hay paso manual — pero tampoco se corre upgrade a mano en este ticket.


13. Fase 6 — CRM: expediente_gateway, clon del gateway de Anexo22

Leer completo antes de escribir una línea: C:\Users\USUARIO\Desktop\anexo 22\anexo22\backend\api\v1\modules\pedimentos\pedimento_gateway\.

13.1 core/efc_client.py — clon de client.py

Copiar prácticamente literal, cambiando las constantes de path a .../integrations/crm/.... Se conserva todo: EfcClientError(message, status_code, code, retryable); retries = 2; backoff 0.15 * (attempt + 1); el corte en 4xx; _parse_error_body; _filename_from_response; _client_kwargs() con mTLS opcional; is_configured; el parámetro transport: httpx.BaseTransport | None —existe para los tests, con httpx.MockTransport—; y la instancia módulo-global efc_client = EfcClient().

httpx.Client síncrono, no async, por la misma razón que el original documenta: el consumidor es el worker de Celery, en contexto sync. El único punto async es el proxy de descarga, que es la fase 7.

Métodos: resolve_organizacion, ingest_expediente, completar_expediente, upload_documento, list_documentos, replace_documento, download_documento. No se clona asignar_anexo22_id.

13.2 crm/expediente_gateway/models.py — las dos tablas

Mismos estados y mismo tope que el original: STATUS_PENDING/SENT/FAILED, MAX_ATTEMPTS = 8.

crm.efc_sync_outbox: kind String(20), payload JSON, expediente_ref Integer index, status String(10) server_default pending, attempts Integer server_default 0, last_error Text, sent_at, efc_pedimento_id String(36). Índices (status) y (kind, status).

crm.efc_file_outbox: kind String(30), s3_key String(1024), file_name String(255), content_type String(100), efc_tipo String(40), source_table String(30), source_id Integer, expediente_ref Integer NOT NULL index, delete_local Boolean server_default true, y el mismo ciclo de vida. Mismos índices.

source_table es un añadido necesario sobre el original. El CRM tiene dos tablas de documentos con secuencias independientes, así que source_id solo es ambiguo. Es el mismo problema que Anexo22 resolvió con _DESTINO_POR_KIND, y su comentario dice exactamente qué pasa si se ignora: un UPDATE con el id de otra tabla vacía la columna de un documento ajeno que tuviera ese mismo entero — daño en el dato de otro, sin un solo error visible. Un (kind, source_table) que no esté en el mapa no toca nada, en vez de caer por omisión.

delete_local es el mecanismo de "EFC es la fuente única". El archivo se guarda en el MinIO del CRM (durable), la fila del outbox referencia su s3_key, y al confirmar la entrega se borra la copia local. "Solo EFC" es el estado FINAL (eventual), no el inmediato.

13.3 crm/expediente_gateway/service.py

Tabla de equivalencias que va en el docstring del módulo, para que quien conozca un carril lea el otro:

Anexo22 CRM
replicate_pedimento_best_effort replicate_expediente_best_effort
_enqueue_pedimento_outbox _enqueue_expediente_outbox
_dispatch_delivery igual
deliver_row / _deliver_pedimento deliver_row / _deliver_expediente
_register_failure idéntico
_ya_entregado(source_id, kind) _ya_entregado(source_table, source_id, kind)
deliver_file_row idéntico, incluidos ensure-then-upload y delete_local
_register_file_failure idéntico
_resolve_org_id + _org_id_cache igual — dict módulo-global, por worker, sin invalidación
list_outbox / retry_outbox_row / outbox_metrics igual, para las dos tablas
find_pedimento_gaps find_expediente_gaps

Ensure-then-upload, literal del original:

try:
    resp = client.upload_documento(...)
except EfcClientError as exc:
    if exc.status_code == 404 and exc.code == "expediente_no_encontrado":
        # La creación del provisional puede venir en camino: se asegura y se reintenta UNA vez.
        client.ingest_expediente({...})
        resp = client.upload_documento(...)
    else:
        raise

Corte directo, con su try propio:

if row.delete_local:
    try:
        from core.storage_s3 import delete_object_if_exists
        delete_object_if_exists(row.s3_key)
    except Exception:
        # Ya está en EFC: no poder borrar la copia local no invalida la entrega.
        logger.warning(...)

Simplificación legítima por ser mono-base: en Anexo22 el outbox se commitea aparte del pedimento (dos bases distintas), y ese doble-commit es justo lo que obligó a inventar sweep_pedimento_gaps. El CRM es mono-base, así que la fila del outbox va en la misma transacción que el expediente o el documento. find_expediente_gaps se conserva igual —cubre lo creado antes de activar la integración y cualquier crash— pero deja de ser el parche de una ventana estructural. Dejarlo escrito en el docstring, o el siguiente que lo lea va a creer que es redundante.

13.4 crm/expediente_gateway/tasks.py

Tarea Beat
expediente_gateway.deliver_outbox_row
expediente_gateway.sweep_outbox 120 s
expediente_gateway.deliver_file_outbox_row
expediente_gateway.sweep_file_outbox 120 s
expediente_gateway.sweep_expediente_gaps 300 s, no-op si not settings.EFC_API_URL

Trampa de RLS — el detalle que más fácil se pasa por alto. core/celery_app.py del CRM materializa el contexto desde los headers rls_tenant_id/rls_company_id, que solo pone api/v1/modules/core/tasks_tracking/dispatch.py::track_and_dispatch. Por tanto:

  • Las tareas por fila deben despacharse con esos headers. Conviene ir por track_and_dispatch, que además queda registrado en el TaskTracker.
  • Los barridos corren sin contexto de tenant: leen los ids pendientes con una sesión sin scope y despachan una tarea hija por fila con sus propios headers. Si un barrido abriera una sesión con scope e iterara, o no ve nada o se salta el aislamiento.
  • LicenseValidationMiddleware es fail-closed contra el Hub pero no corre en el worker: el outbox no debe asumir contexto de licencia.

13.5 crm/expediente_gateway/routes.py

APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"]), bajo /v1/crm. Auth normal del CRM, tenant del token, company_id del query. Los tres endpoints del contrato §5.2.

13.6 Archivos a modificar

Archivo Qué cambia Ancla de texto
core/config.py Las 8 variables de EFC el bloque de S3_ENDPOINT_URL — insertar después
core/celery_app.py include del módulo + 3 entradas de beat_schedule el include= y el beat_schedule
api/v1/modules/crm/router.py include_router del gateway el include_router que dejó la fase 5
.env.example Las 8 variables, todas vacías fin del archivo
docker-compose.prod.yml Las 8 variables en api, worker y beat los environment: de los tres servicios
tests/conftest.py Import de los modelos del gateway los imports que dejó la fase 5

Se reusan los nombres de variable que Anexo22 ya definió, no se inventan otros:

EFC_API_URL: str = ""
EFC_API_KEY: str = ""              # == CRM_INTEGRATION_API_KEY del lado de EFC
EFC_API_VERIFY_SSL: bool = True
EFC_API_TIMEOUT_MS: int = 8000     # metadatos: resolver, ingest, completar
EFC_UPLOAD_TIMEOUT_MS: int = 55000 # subidas
EFC_MTLS_CA_PATH: str = ""
EFC_MTLS_CERT_PATH: str = ""
EFC_MTLS_KEY_PATH: str = ""

EFC_API_URL pasa por el field_validator(mode="before") que ya sanea las otras URLs del repo.

EFC_UPLOAD_TIMEOUT_MS es un añadido necesario: los 8000 ms del original sirven para metadatos, no para un archivo de 25 MB. Y tiene que quedar por debajo del proxy_read_timeout del nginx de EFC: si el timeout del CRM es mayor, el CRM ve un 504 opaco y no sabe si el documento entró. Fallando primero del lado del CRM, el reintento con el mismo crm_document_ref es limpio.

Los tres servicios necesitan las variables: la entrega la hace el worker y el barrido el beat.

Migración: agrega migración de Alembic con las dos tablas de outbox. down_revision = la de la fase 5.


14. Fase 7 — CRM: subida de un paso, proxy de descarga y UI

14.1 Backend

Archivo Qué cambia Ancla de texto
api/v1/modules/crm/expedientes/routes.py Los dos endpoints nuevos el router de la fase 5
api/v1/modules/crm/expedientes/service.py attach_document, stream_document, detach_document las funciones de la fase 5
core/storage_s3.py put_object_stream(key, fileobj, content_type), open_object_stream(key) def put_object_bytes
core/s3_keys.py expediente_document_key(...) def csv_import_key
api/v1/modules/crm/uploads/routes.py Arreglar el read-antes-de-validar; allowlist; acotar el chequeo de prefijo MAX_UPLOAD_BYTES y prefix = f"tenants/
api/v1/modules/crm/documents/service.py delete_document encola DETACH en vez de solo marcar deleted_at def delete_document

Tres defectos preexistentes que esta fase corrige de paso:

  1. POST /v1/crm/uploads hace await file.read() completo antes de validar el tamaño → un archivo de 2 GB se bufferiza en RAM antes del 422.
  2. No hay allowlist de MIME ni de extensión, a diferencia del avatar y del centro de ayuda, que sí la tienen.
  3. GET /v1/crm/uploads/url valida solo que la key empiece con tenants/{tid}/companies/{cid}/, lo que permite firmar una URL para cualquier objeto de esa company —fin-invoices/, certificates/, imports/csv/— con solo crm.access.

Secuencia del POST de documentos:

  1. Escribir en streaming al MinIO del CRM contando bytes y abortando al superar el tope. Nunca await file.read().
  2. Validar extensión/MIME contra la allowlist y doc_type contra doc_types.EFC_DOC_TYPES → 422.
  3. Resolver el expediente (tenant/company/deleted_at IS NULL) → 404.
  4. efc_document_ref = f"{TABLA}-{company_id}-{row_id}" una vez creada la fila.
  5. En una transacción: crear la fila del documento (efc_sync_state='PENDING', file_key = la key local, content_sha256) y la fila de crm.efc_file_outbox con delete_local=True, previo _ya_entregado.
  6. _dispatch_file_delivery(...) best-effort y responder 201.

Proxy de descarga:

# EFC nunca entrega una URL de MinIO: reescribir el host de una URL ya firmada invalida SigV4
# (documentado en api/customs/serializers.py de EFC). Así que el CRM hace de segundo proxy.
#
# Dos guardas, las mismas que los tests del proxy de Anexo22 fijan:
#  1. Validar la pertenencia ANTES de tocar EFC. Sin esto, un document_id coincidente leería el
#     expediente de otro tenant — y peor: el organizacion_id se deriva del expediente, así que el proxy
#     iría a preguntar a la organización de otro cliente.
#  2. Mandar el organizacion_id del expediente, para que la verificación de EFC también dispare.
#
# El AsyncClient se crea DENTRO del generador y se cierra en finally. Si se creara en un `async with`
# que cierra antes de que empiece el streaming, la respuesta muere a medias.
async def _iter_upstream(...):
    client = httpx.AsyncClient(...)
    try:
        async with client.stream("GET", url, headers=...) as upstream:
            ...
            async for chunk in upstream.aiter_bytes():
                yield chunk
    finally:
        await client.aclose()

Traducción de errores: EfcClientError502, salvo 404 que pasa como 404. La asimetría es deliberada y está fijada por test en Anexo22.

Borrado: delete_document marca deleted_at y encola op='DETACH'. No destruye en EFC.

El gateway de Anexo22 nunca llama al DELETE de EFC —verificado: no existe el método en su cliente—. record.Document en EFC no tiene vigencia ni purga, así que la política implícita del sistema es conservar, y un documento que mañana puede ser parte del expediente de un pedimento real es riesgo de retención fiscal.

Migración: no agrega migración (las columnas las dejó la fase 5).

14.2 Frontend — frontend/

Archivos nuevos: src/lib/api/expedientes.ts (list, get, ensure, uploadExpedienteDocument, expedienteDocBlob, retrySync) y src/lib/components/ops/ExpedienteDocuments.svelte (tabla con badge de efc_sync_state, botón de reintento, modal de alta). SvelteKit 5 con runes.

Archivo Qué cambia Ancla de texto
src/lib/api/uploads.ts Añadir las funciones nuevas. uploadFile y uploadUrl se conservan para lo legacy y para facturas export async function uploadFile
src/lib/components/crm/RelatedManager.svelte onFilePicked y openDoc se ramifican por efc_document_id con fallback a file_key; badge y reintento en la tabla; deshabilitar "URL externa" para documentos de expediente async function onFilePicked, async function openDoc, y el campo de URL externa del modal
src/routes/dashboard/ops/embarques/[id]/+page.svelte Usar ExpedienteDocuments onFilePicked y openDoc de esa página
src/lib/api/crm/types.ts y el de ops expediente_id, efc_document_id, efc_sync_state opcionales las interfaces Document y ShipmentDocument

window.open(url, '_blank', 'noopener') no lleva el header Authorization. Para documentos de expediente hay que usar fetchblobURL.createObjectURL, con el api.getBlob que ya existe en src/lib/api.ts. Bufferiza en memoria del navegador, no del servidor; para archivos muy grandes habrá que migrar a un token firmado en el query string, y eso es otro ticket.

Barra de progreso: src/lib/api.ts ya tiene CsvFormDataUploadOptions con onProgress, pero uploads.ts usa api.request plano. Reusar esa opción.

DOC_TYPES y SHIPMENT_DOC_TYPES de src/lib/api/crm/format.ts no cambian: son la fuente del mapeo, y ahora el backend los valida.

14.3 Textos (es-MX) — fuente única

Situación Texto exacto
Badge PENDING Pendiente de enviar
Badge SYNCED En expediente
Badge FAILED No se pudo enviar
Botón de reintento Reintentar envío
Tooltip del badge FAILED El documento está guardado, pero todavía no llegó al expediente electrónico. Vuelve a intentarlo o avisa a soporte.
Error de doc_type inválido Ese tipo de documento no está en el catálogo.
Error de extensión Ese tipo de archivo no está permitido.
Error de tamaño El archivo excede el tamaño máximo permitido.
Error del proxy de descarga No se pudo obtener el archivo del expediente electrónico.
Modal de alta, título Nuevo documento del expediente

15. Verificación

15.1 Tests automatizados — EFC (cd backend && python manage.py test)

api/organization/tests_integrations_crm.py

# Caso Qué prueba
1 Sin header X-Api-Key 403
2 CRM_INTEGRATION_API_KEY = '' y header vacío 403 — el fail-closed
3 Key incorrecta 403
4 Resolver tenant_slug nuevo 201, is_verified=True, licencia con almacenamiento > 0
5 Resolver dos veces 200 la segunda, mismo id
6 Organización con is_verified=False queda en True (auto-reparador)
7 Resolver NO enciende credenciales_desde_mve ni apply_auto_download

api/customs/tests_integrations_crm.py

# Caso Qué prueba
1 Crear provisional 201; consultar_vucem is False; pedimento == folio; numero_operacion == folio
2 Crear dos veces el mismo crm_expediente_id 200, mismo pedimento_id, un solo enlace
3 storage_token = "00-00-0000-0000000" 400 pedimento_app_reservado, cero filas
4 storage_token de 26 caracteres 400 storage_token_invalido
5 Crear procesar_pedimento_completo_individual.apply_async no se llama (patch)
6 Re-crear api.customs.signals.procesamiento.sleep NO se llama ← la prueba de H1
7 Completar pedimento_app cambió, datos poblados, estado == 'completado', consultar_vucem sigue en False
8 Completar sleep, crear_procesamiento_remesa y crear_procesamiento_partida no se llaman
9 Completar hacia un pedimento_app que ya existe 409 pedimento_real_ya_existe, provisional intacto
10 Completar uno ya completado 409 expediente_ya_completado
11 Completar sin aduana 400 efc_pedimento_app_incompleto
12 is_verified=False 409 organizacion_no_utilizable
13 Licencia en 0 GB 409 licencia_sin_espacio
14 Pedimento.delete() con enlace vivo ProtectedError ← la prueba de H4

api/record/tests_integrations_crm.py

# Caso Qué prueba
1 POST multipart válido 201; nombre_original == file.name; fuente.nombre == "APP-CRM"
2 POST document_type.id >= 27 y documento.vu is False ← el test de H2, el de mayor valor del ticket
3 DocumentType sembrado con pocas filas el id asignado no cae en 13..26
4 El documento creado no aparece en el queryset de los tres bulk-delete-*-vu
5 POST el objeto queda bajo el prefijo del storage_token, no del pedimento_app
6 Dos POST con el mismo crm_document_ref el segundo es 200 con el mismo id; la cuota no creció; save_document no se llamó
7 Cuota agotada 400 espacio_insuficiente y sin huérfano: delete_file llamado
8 ValueError desde Document.save() igual que 7
9 crm_expediente_id inexistente 404 expediente_no_encontrado
10 tipo fuera del catálogo 400 tipo_invalido, cero filas
11 Extensión .exe 400 extension_no_permitida
12 DELETE de un documento APP-MVE 403, intacto
13 DELETE de un documento VU 403, intacto
14 DELETE / descargar / listar de otra organización 404, no 403
15 PUT reemplazar, subida falla el anterior sigue descargable
16 PUT reemplazar, éxito save_document antes de delete_file; vu/FKs preservadas
17 Alta y baja _recalcular_cumplimiento llamado (patch)

storage_service parcheado: los tests no tocan MinIO.

api/record/tests_integrations_anexo22.pyverificado que no existe ni un test de los endpoints M2M de MVE ni de Anexo22. El carril del CRM importa _err, _resolver_pedimento, _doc_to_dict y _resolver_document_type de ellos, así que fijarlos es prerrequisito. Mínimo: idempotencia por (anexo22_document_id, tipo), el rango VU de _resolver_document_type, y la guarda de fuente en el borrado.

api/customs/tests_cumplimiento_provisional.py — regresión de H3: provisional en una organización con checklist TipoChecklist.TODOS mantiene las 8 columnas cumplimiento_* en NULL.

api/customs/tests_provisionales_grid.py/pedimentos/ los excluye por defecto; ?incluir_provisionales=true los incluye; ?crm_expediente_folio= filtra; export-excel y activo-fijo heredan; la forma de la respuesta no cambia para los consumidores React actuales; un provisional completado vuelve a aparecer.

15.2 Tests automatizados — CRM (cd backend && pytest tests/ -v)

tests/test_efc_client.py — el hueco que el carril de referencia tiene y que aquí no debe repetirse: verificado que no existe ni un test de EfcClient._request en Anexo22, así que el bucle de reintentos, el backoff, el corte en 4xx y el header nunca se ejercitan. Con httpx.MockTransport por el parámetro transport:

# Caso Qué prueba
1 500 y luego 200 reintenta y devuelve el éxito
2 500 siempre exactamente 3 intentos, retryable=True
3 Timeout siempre 3 intentos, retryable=True
4 400 con {"error":{"code":"espacio_insuficiente"}} un solo intento, code extraído, retryable=False
5 401 / 403 no se reintentan
6 Cuerpo de error que no es JSON no revienta, code is None
7 Cualquier llamada X-Api-Key presente con el valor de settings
8 EFC_API_URL vacía is_configured is False, error no-retryable sin tocar la red
9 base_url con y sin / final misma URL

tests/test_efc_outbox.py

# Caso Qué prueba
1 _register_failure con retryable=True attempts=1, sigue pending
2 _register_failure con retryable=False failed de inmediato
3 Hasta attempts = MAX_ATTEMPTS failed
4 Excepción de 5000 caracteres last_error truncado a 2000
5 deliver_row con EFC lanzando no propaga la excepción
6 deliver_row sobre una fila sent retorna sin llamar a EFC
7 _ya_entregado con una fila sent del mismo (source_table, source_id, kind) no se encola otra
8 Mismo source_id en distinta source_table se encola ← la prueba de la ambigüedad de las dos secuencias
9 Barrido recoge pending viejos y despacha con headers RLS
10 retry_outbox_row resetea attempts=0, last_error=None, pending, re-despacha
11 retry_outbox_row de otro tenant False
12 find_expediente_gaps incluye los sin fila; excluye los failed
13 EFC_API_URL vacía no encola nada
14 El encolado falla rollback y return None, la operación local no se rompe

tests/test_efc_entrega_documento.py — entrega feliz; ensure-then-upload (404 expediente_no_encontrado → crea el provisional y reintenta una vez); 404 con otro code no hace ensure; delete_local=True borra el objeto local; si el borrado local falla la fila sigue sent; delete_local=False no borra; reintento tras timeout con el mismo ref no duplica.

tests/test_expediente_folio.py — formato; tres seguidos 001/002/003; reinicio mensual; independencia por company; N sesiones concurrentes → N folios distintos (requiere PostgreSQL, marcar para saltar en SQLite); rollback deja hueco y el siguiente avanza.

tests/test_expedientes.pyensure_* idempotente; crear una solicitud nace con folio; dos expedientes con el mismo (tenant, company, folio)IntegrityError; aislamiento por tenant/company.

tests/test_expediente_upload.py — 201 con fila PENDING y fila de outbox; archivo por encima del tope → 422 sin haberlo leído completo; el tope se aplica antes de consumir el cuerpo; .exe → 422; doc_type inválido → 422; expediente de otro tenant → 404; EFC caído → sigue devolviendo 201 y la fila queda PENDING.

tests/test_expediente_download.py — el proxy hace streaming y cierra la respuesta httpx; documento de otro tenant → 404; ninguna URL de MinIO en cuerpo ni headers; EfcClientError → 502 y 404 de EFC → 404.

tests/test_gateway_rutas.pyretry inexistente → 404 con mensaje específico; éxito → {"status":"requeued","id":...}; metrics cuenta por status; aislamiento por tenant/company.

tests/test_uploads_alcance.pyGET /uploads/url con una key de fin-invoices/403.

tests/test_doc_types_paridad.pyEFC_DOC_TYPES del CRM == las 22 claves de TIPOS_DOCUMENTO_CRM de EFC. La lista está duplicada a mano en dos repos; este test es lo que la mantiene honesta.

Tests de contrato: un fixture JSON compartido con las formas de request/response y los códigos de error por endpoint, afirmado en las dos suites. Es lo único que atrapa la deriva entre dos repos con despliegue independiente.

15.3 Verificación manual — documento Pruebas Ingeniería

Requiere EFC arriba con las fases 1-4, y el worker + beat del CRM corriendo.

  1. Poner CRM_INTEGRATION_API_KEY en el .env de EFC y EFC_API_URL/EFC_API_KEY en el del CRM. Reiniciar api, worker y beat de ambos.
  2. curl al resolver de organización con la key → 200/201 con el UUID. Repetir con una key incorrecta → 403.
  3. En el admin de EFC, confirmar is_verified=True y la licencia asignada.
  4. Crear una solicitud desde la pantalla del CRM → confirmar el folio EXP2026-08-0NN. Crear tres más → consecutivos sin huecos.
  5. En el módulo Expedientes del frontend de EFC → los provisionales no aparecen. curl con ?incluir_provisionales=true → aparecen, con aduana y patente vacías.
  6. Intentar borrar un provisional desde el admin de EFC → debe negarse (PROTECT).
  7. En la ficha del embarque, adjuntar un PDF → barra de progreso, y badge En expediente al confirmar.
  8. En la consola de MinIO del CRM, confirmar que el objeto local ya no está (corte directo). En la de EFC, confirmar la ruta org_{uuid}/documents/CRM-1-EXP2026-08-001/.
  9. En el admin de EFC, confirmar fuente = APP-CRM, vu = False, document_type.id >= 27.
  10. Abrir el documento desde el CRM → el PDF se descarga y se ve.
  11. Apagar EFC. Adjuntar otro → la subida tiene éxito con badge Pendiente de enviar. En GET /expediente-gateway/outbox, ver attempts subir y last_error poblado cada dos minutos.
  12. Levantar EFC. Esperar un barrido → badge En expediente, y en EFC hay un solo documento.
  13. Apagar EFC ~17 minutos para forzar 8 fallos → la fila queda failed y deja de reintentarse. POST /outbox/{id}/retry → vuelve a pending y se entrega.
  14. GET /expediente-gateway/metrics → los conteos cuadran con el listado.
  15. Completar el expediente con pedimento dummy 0000-0000000 → 200; el pedimento_app cambió, los documentos siguen colgados y descargables, y el objeto en MinIO no se movió.
  16. Recargar el módulo Expedientes de EFC → ahora aparece, con sus datos.
  17. Intentar adjuntar un .exeEse tipo de archivo no está permitido. Un archivo mayor al tope → mensaje de tamaño, y el navegador no se queda colgado subiendo el archivo completo.
  18. Borrar un documento del expediente en el CRM → desaparece del CRM y sigue existiendo en EFC.
  19. Probar la UI en tema claro y oscuro, y en un ancho de móvil.

16. Fuera de alcance

  • La fusión de documentos de un provisional a un pedimento real preexistente. El completado devuelve 409 y no mueve nada. PENDIENTE DECISIÓN: si se quiere un ?modo=fusionar con opt-in explícito, es un ticket aparte y debe rechazar si el provisional tiene documentos de otra fuente.
  • Que los documentos del CRM cuenten en cumplimiento_porcentaje. Llegan con tipo_documento_expediente = NULL porque mapa_tipos_por_legacy solo cubre los 7 ids legacy de sistema — exactamente lo que le pasa a Anexo22. PENDIENTE DECISIÓN: si se quiere que cuenten, hay que sembrar TipoDocumentoExpediente por organización con nomenclaturas.
  • Ligar la API key a una organización, mTLS y require_permission. Deuda heredada del carril de referencia, documentada en el docstring de la clase de permiso.
  • El PDF de factura (fin.invoices). Su pantalla y send_invoice no se tocan. Cuando entre, será con crm_document_ref = INVOICE-{cid}-{id} (estable → idempotente sin key del cliente).
  • El backfill de documentos legacy del MinIO del CRM. Las filas con file_key se quedan con efc_document_id = NULL y la descarga se ramifica en tres (efc_document_id → proxy; file_key → presigned; file_url → externa). Cuando se haga, va por el mismo outbox con crm_document_ref = LEGACY-{tabla}-{id} (estable → re-ejecutable sin duplicar), y antes de empezar hay que sumar bytes y compararlos contra la cuota disponible.
  • La limpieza de objetos huérfanos del bug histórico de delete_document: script aparte, dry-run por defecto, desacoplado de este trabajo.
  • Una pestaña "Provisionales (CRM)" en el frontend de EFC. Los dos campos nuevos del serializer la habilitan sin más backend.
  • Un token firmado para descargas grandes.
  • PENDIENTE DECISIÓN (no bloquean nada de este ticket): NNN da 999 expedientes por mes y por company —ensancharlo después invalida folios ya comunicados—; una organización EFC por tenant significa que todas las companies comparten cuota y visibilidad; MAX_ATTEMPTS = 8 y barridos de 120 s son heredados de Anexo22, confirmar si sirven para el volumen del CRM; y la exclusión de provisionales del grid es un cambio visible para los operadores de EFC, hay que confirmarlo con ellos antes de mergear.

17. Prohibiciones específicas de este ticket

EFC

  • Nunca pedimento_obj.save(). Solo Pedimento.objects.filter(...).update(...).
  • DocumentType.objects.get_or_create() está prohibido. Solo _resolver_document_type.
  • No poner consultar_vucem=True en ninguna ruta de este ticket.
  • No agregar columnas a Pedimento. Todo el acoplamiento en pedimento_expediente.
  • No modificar _doc_to_dict. Extenderlo con _crm_doc_to_dict.
  • No pasar pedimento.pedimento_app a save_document. Siempre enlace.storage_token.
  • No borrar el objeto de MinIO antes de subir el nuevo en el reemplazo.
  • No usar NamedTemporaryFile + atexit en la descarga.
  • No reusar MVE_INTEGRATION_API_KEY ni ANEXO22_INTEGRATION_API_KEY.
  • No aplicar la exclusión de provisionales a DescargaExpedienteViewSet ni a los bulk-delete de VU.
  • No quitar ni renombrar ningún campo existente de PedimentoSerializer.
  • No dejar que el CRM cree organizaciones sin is_verified=True.

CRM

  • No usar SELECT max(sequence) + 1. El contador con ON CONFLICT, nada más.
  • No hacer commit dentro de next_folio.
  • No hacer la llamada HTTP a EFC dentro del request del usuario. Encolar y despachar.
  • No convertir EfcClient a async. httpx.Client síncrono, como el original.
  • No agregar autoretry_for, retry_backoff ni max_retries a las tareas: duplicaría el mecanismo de reintento que ya está en el cliente y en el barrido.
  • No dejar que un fallo de EFC propague una excepción desde deliver_row.
  • No borrar el objeto local antes de confirmar la entrega.
  • No usar await file.read() para el archivo completo.
  • No devolver file_key ni file_url en la respuesta del endpoint nuevo.
  • No crear el AsyncClient del proxy fuera del generador.
  • No llamar al DELETE de EFC desde el soft delete.
  • No romper uploadFile/uploadUrl: las filas legacy y la pantalla de facturas los usan.
  • No reusar core/s3_keys.py::expediente_archivo_document_key ni expediente_archivo_artifact_key: son código muerto de la plantilla y se refieren al expediente del importador de EFC, que es otro concepto (cuelga de Importador por RFC, reglas 1.4.14/3.1.42). Sus nombres van a confundir.
  • No inventar nombres de variables de entorno: reusar los de Anexo22.
  • SvelteKit 5 con runes, nunca la sintaxis de v4.

Ambas

  • No aplicar migraciones. Se generan y se dejan sin aplicar. Ni migrate ni alembic upgrade.

Lo que este ticket NO debe traer

  • Entrada de CHANGELOG.md. El changelog lo escribe el orquestador al final, en un PR aparte (Orquestacion.md §9). Si algo de arriba pareciera pedirlo, esa instrucción queda derogada.
  • Instrucciones de abrir el PR. El agente pushea las ramas y deja título, cuerpo y link de compare; el PR lo abre una persona.
  • Pasos que esperen aprobación. No hay nadie despierto.
  • Credenciales, tokens, contenido de .env ni datos de clientes reales.
  • Instrucciones de registrar la actividad en el Kanban. El agente lo hace solo al cerrar el ticket, en el ticket P espejo.

Ambigüedad durante la corrida

Si algo no está en este ticket ni en el código, no adivinar: documentar como PENDIENTE DECISIÓN: <qué falta y qué se asumió> en el cuerpo del PR y seguir con la asunción más conservadora.

Riesgos conocidos que no cierra este ticket

  • resolver_fk después del completado. Document.save() re-resuelve partida/cove/edocument por heurística de nombre. En un provisional no hay partidas, pero el pedimento real sí las tiene, así que cualquier save() sobre un documento del CRM podría auto-ligarlo a una partida ajena. Mitigación: nunca save() un documento del CRM después de crearlo, y un test que afirme que esas tres FK siguen en NULL.
  • _construir_pedimento_app tiene tres implementaciones en la casa que deben coincidir. El carril de EFC la importa; si alguien las desalinea, el completado puede generar una llave que no case con la que ya creó datastage.
  • Enforcement de permisos en el CRM. El repo registra ~48 permisos por entidad pero no los aplica en ninguna ruta: solo hay gate de módulo (crm.access). Para un carril que escribe documentos fiscales en otro sistema conviene aplicarlos por ruta con PermissionChecker, y es un ticket aparte.
  • .env.example de EFC contiene valores reales (contraseña de BD y de SMTP) y hay ~6.5 GB de dumps de producción en la raíz de ese repo. Fuera del alcance, pero hay que rotar esas credenciales y revisar el .gitignore. Mismo cuidado con el .env de Anexo22.