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

1471 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

> **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.**
```python
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_slug``Tenant.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:
```json
{"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):
```json
{"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 documento**`parser_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 archivo**`StreamingResponse` 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.py`** — `CrmOrganizacionSearchView` (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` |
```python
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`.
```python
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.py`** — `CrmExpedienteProvisionalView`, `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:
```python
class _PedimentoCrmDataSerializer(_PedimentoDataSerializer):
fecha_pago = serializers.DateField(required=False, allow_null=True)
```
Guarda de reserva, **antes** de cualquier escritura:
```python
_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:
```python
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:
```python
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:
```python
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`.
```python
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 ValueError` → `storage_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 IntegrityError` → `delete_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) |
```python
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.
```
```python
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_index` — `atomic = 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.
```python
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`**:
```python
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.
"""
```
```sql
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.py`** — `ensure_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.py`** — `EFC_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:
```python
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:
```python
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:
```python
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:
```python
# 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: `EfcClientError`**502**, 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 `fetch` → `blob` → `URL.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.py`** — **verificado 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` | **sí** 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.py`** — `ensure_*` 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.py`** — `retry` inexistente → **404** con mensaje específico; éxito →
`{"status":"requeued","id":...}`; `metrics` cuenta por status; aislamiento por tenant/company.
**`tests/test_uploads_alcance.py`** — `GET /uploads/url` con una key de `fin-invoices/`**403**.
**`tests/test_doc_types_paridad.py`** — `EFC_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 **sí** aparece, con sus datos.
17. Intentar adjuntar un `.exe``Ese 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.