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
1471 lines
85 KiB
Markdown
1471 lines
85 KiB
Markdown
> **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.
|