From 59fb371f5d70b346b00d55092caba4ba2b86339d Mon Sep 17 00:00:00 2001 From: marcos Date: Mon, 10 Aug 2026 07:50:41 -0600 Subject: [PATCH] docs(crm): abre el changelog del repo y versiona el plan de T2026-08-046 El CRM no tenia CHANGELOG.md. Se crea con el formato del de EFC para que un ticket que cruza los dos productos se lea igual de los dos lados, y se abre con la entrada de T2026-08-046 (fases 5-7). Va en un PR aparte del codigo a proposito: el changelog es el unico archivo garantizado en colision entre tickets, y metido en cada PR de codigo convierte cada merge en una resolucion de conflictos en vez de una revision. La entrada dice tambien lo que NO esta: el lado de EFC del carril todavia no aterriza, las variables de entorno de produccion quedaron sin tocar y el contrato entre repos esta afirmado solo desde el CRM. Un changelog que afirma un entregable completo cuando esta a medias es peor que ninguno. Se versiona ademas el documento Prompt del ticket en docs/planes/, siguiendo la convencion de EFC, para que las decisiones del codigo tengan su porque a la vista sin depender de una carpeta de corrida. Un dato se sustituyo al versionar: un caso de prueba traia un token con forma de llave de pedimento real y aqui va como dummy; el caso prueba que esa FORMA se rechace, asi que el numero concreto no aportaba nada. Refs: T2026-08-046 --- CHANGELOG.md | 90 ++ docs/planes/T2026-08-046_prompt.md | 1470 ++++++++++++++++++++++++++++ 2 files changed, 1560 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/planes/T2026-08-046_prompt.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6f515ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +# Changelog — CRM Agentes de Carga + +Historial de cambios por ticket (más reciente arriba). Cada entrada: fecha, ticket, tipo, repos +afectados, qué se hizo y por qué. El formato es el mismo que el de `EFC/backend/CHANGELOG.md`, para +que un ticket que cruza los dos productos se lea igual de los dos lados. + +Este archivo lo abre la corrida SUNRISE del 2026-08-07: el repo no tenía changelog. Las entradas +llegan en un PR de documentación aparte, nunca dentro del PR de código — el `CHANGELOG.md` es el +único archivo garantizado en colisión entre tickets, y metido en cada PR convierte cada merge en una +resolución de conflictos. + +--- + +## T2026-08-046 (feature, fases 5-7) — Expediente electrónico del CRM y entrega de documentos a EFC + +- **Fecha:** 2026-08-07 +- **En corto:** Cada solicitud de servicio nace ya con su expediente y su folio propio, que el usuario + ve en cuanto guarda. Los documentos de un embarque se adjuntan en un solo paso y viajan solos al + expediente electrónico; si ese sistema no responde, el documento **no se pierde**: se queda guardado + aquí, la pantalla lo muestra como pendiente de enviar, y se entrega solo cuando el otro lado vuelve. + Quien lo necesite puede abrirlo desde el CRM aunque el archivo ya viva del otro lado. +- **Tipo:** feature +- **Repos:** CRM (backend + frontend). **Las fases 1-4, del lado de EFC, son de otra corrida y todavía + no están** — ver «Lo que falta» al final de la entrada. +- **Branch:** `feature/T2026-08-046` · **PR:** (pendiente de abrir a mano) +- **Inicio:** 2026-08-07T17:40:46 · **Fin:** 2026-08-10T08:00 (la corrida estuvo tres días en pausa + entre medias; el detalle está en `SUNRISE/2026-08-07/Diario-2026-08-07.md`) +- **Con dos migraciones, generadas y SIN aplicar:** `e6f7a8b9c0d1_crm_expedientes.py` y + `f7a8b9c0d1e2` (el outbox del carril). `alembic heads` devuelve **una sola hoja** después de las + dos; el `down_revision` se verificó con `alembic heads`, no se supuso. + +- **Contexto:** el CRM guardaba los documentos de sus embarques en su propio almacén y ahí se + quedaban. El expediente electrónico —donde de verdad se consultan— vive en EFC, así que había que + volver a subir cada documento a mano del otro lado, o no subirlo. Y el enlace entre un expediente + del CRM y un pedimento de EFC no podía colgarse de una columna nueva en la tabla de pedimentos: el + expediente del CRM nace **antes** de que exista pedimento alguno. + +- **Qué se hizo:** + - **El expediente y su folio.** Una solicitud de servicio crea su expediente en la misma + transacción del alta: si algo falla, no queda ni la solicitud ni un folio quemado. El consecutivo + lo lleva un contador con bloqueo por `(cliente, empresa, mes)` que **reinicia cada mes**, en una + sola sentencia atómica —sin leer-modificar-escribir— para que dos altas simultáneas no puedan + sacar el mismo número. La restricción de unicidad del folio lo garantiza aunque el contador + fallara. + - **El carril de entrega hacia EFC.** Clon del carril que ya usa Anexo22, con sus tres capas de + reintento: el cliente HTTP reintenta lo transitorio y **corta en seco** ante un error del + llamador, la fila pendiente se reintenta con límite de intentos, y un barrido periódico recoge lo + que se quedó atrás. Cuatro capas de idempotencia impiden que un reintento duplique un documento + del otro lado. **La llamada a EFC nunca ocurre dentro de la petición del usuario**: se encola y se + despacha. + - **La subida de un paso y el proxy de descarga.** Adjuntar un documento guarda, registra y encola + en una sola operación, y responde **aunque EFC esté apagado**. Para abrirlo, el CRM hace de proxy + con streaming: el otro sistema nunca entrega una dirección de descarga reutilizable, y reescribir + una dirección ya firmada la invalida. + - **La pantalla.** La ficha del embarque muestra cada documento con su estado —«Pendiente de + enviar», «En expediente», «No se pudo enviar»— y un botón para reintentar el envío cuando algo + falló, sin que nadie tenga que entrar a la base. + +- **Tres defectos preexistentes corregidos de paso**, todos en la subida de archivos y todos + autorizados por el ticket: un archivo se leía **entero en memoria** antes de comprobar si excedía el + tamaño máximo (un archivo de 2 GB se bufferizaba solo para rechazarlo); no había lista de + extensiones permitidas, a diferencia del avatar y del centro de ayuda, que sí la tienen; y firmar + una dirección de descarga solo comprobaba el prefijo de la empresa, lo que permitía alcanzar + **cualquier** objeto de esa empresa —facturas, certificados, importaciones— con el permiso más + básico del CRM. + +- **Un defecto propio, encontrado y corregido antes de mergear:** el proxy de descarga comprobaba la + respuesta de EFC **demasiado tarde**, ya dentro del envío del archivo. Para entonces la respuesta + del CRM ya había salido como «correcta», así que un documento que EFC no encontraba llegaba al + usuario como un archivo vacío en vez de como un error. Ahora la comprobación ocurre antes de + empezar a responder. Lo destapó una prueba que el ticket pedía y que la fase original no dejó + escrita. + +- **Verificación:** `pytest tests/ -q` → **215 pasan, 1 se salta**, contra las 70 del punto de + partida; ningún rojo nuevo. En el frontend, la revisión de tipos y las pruebas unitarias quedan + **exactamente** en los mismos números rojos que ya tenían antes de este trabajo (38 y 2, ambos + heredados y ajenos al ticket). Cada verde se vio fallar primero: seis roturas deliberadas, + comprobando que la prueba se pusiera roja nombrando lo que faltaba, y restauradas. + +- **Lo que falta, y hay que saberlo antes de mergear:** + - **El lado de EFC (fases 1-4) no está.** Sin él, el carril entrega a un endpoint que todavía no + existe: los documentos se guardan en el CRM y quedan «pendientes de enviar» hasta que aterrice. + Eso es degradar limpio, no romper — pero nadie debería mergear esto creyendo que el circuito está + cerrado de los dos lados. + - **Las variables de entorno de producción no se tocaron.** El worker y el proceso periódico de + producción no ven la dirección de EFC, así que allá el carril queda **apagado** hasta que alguien + las agregue a mano. Está en la lista de pasos manuales del reporte de la corrida. + - **El contrato entre los dos repos está afirmado solo de este lado.** El archivo que lo describe + vive en el CRM y la suite del CRM lo comprueba; EFC debe afirmar su mitad contra una copia + idéntica cuando lleguen sus fases. diff --git a/docs/planes/T2026-08-046_prompt.md b/docs/planes/T2026-08-046_prompt.md new file mode 100644 index 0000000..e9792da --- /dev/null +++ b/docs/planes/T2026-08-046_prompt.md @@ -0,0 +1,1470 @@ +> **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: `. + +``` +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": "", "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":}`; 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: ` 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.