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
85 KiB
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:
- 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.- Un dato se sustituyó al versionar. El caso 3 de la tabla de pruebas de EFC traía un
storage_tokencon forma de llave de pedimento real; aquí va como00-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.mdy 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) + EFCbackend· Prioridad: NORMAL · Tipo: NUEVO REQUERIMIENTO · Rama:feature/T2026-08-046en 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.mdsolo opera sobrebackend,frontendymicroservicede EFC. La copia de este ticket enEFC/SUNRISE/2026-08-07/T2026-08-046.mdcubre 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 deSUNRISE/VENTANA-NOCTURNA-AUTONOMA.mdde este repo, o ejecución interactiva.
1. Contexto
Problema
El CRM de agentes de carga genera documentos —constancia fiscal, MBL/HBL/MAWB, factura comercial, packing
list, PDF de factura— que hoy viven en su propio MinIO: sin expediente que los agrupe, sin catálogo validado
en backend, y con un delete_document que hace soft delete y nunca borra el objeto
(backend/api/v1/modules/crm/documents/service.py, función delete_document). EFC es el sistema de
expedientes electrónicos de la casa y debe ser la fuente única de esos archivos.
El obstáculo estructural. En EFC el expediente es el Pedimento, y record.Document.pedimento es FK
NOT NULL con llave de negocio pedimento_app = AA-XX-PPPP-NNNNNNN. El CRM no tiene ni un dato de
pedimento: verificado que no existe ningún campo de aduana, patente, número ni año en los modelos de crm/
ni de ops/; ops.shipments solo tiene customs_agent_id (FK a crm.suppliers).
Y el CRM no tiene entidad expediente ni generador de folio. Verificado: las tres apariciones de
"expediente" son herencia de plantilla —core/s3_keys.py tiene expediente_archivo_document_key y
expediente_archivo_artifact_key con cero llamadores, y alembic/versions/9db46c604463_initial_schema.py
crea la tabla huérfana a76.expediente_archivo sin modelo ni ruta—. reference en crm.service_requests,
crm.quotes, ops.shipments y fin.invoices es String(40) nullable sin server_default, sin trigger y
sin código que lo genere.
Qué se entrega
Un expediente en el CRM con folio EXP2026-08-001 generado al crear una solicitud, un pedimento
provisional en EFC por cada uno, y un carril máquina-a-máquina que sube los documentos del CRM al
expediente de EFC con reintentos, borrando la copia local al confirmar. Cuando llega la data aduanera real, el
provisional se completa sin mover un solo archivo.
Resultado esperado
El usuario adjunta un MBL desde la ficha del embarque y lo ve aparecer con badge En expediente. Si EFC está
caído, lo ve como Pendiente de enviar en vez de perder su trabajo, y el sistema lo entrega solo cuando EFC
vuelve, sin duplicarlo. Un operador de EFC abre el expediente y ve el documento con fuente = APP-CRM.
Por qué el enlace va en una tabla desechable
Anexo22 es el sistema centralizado de pedimentos y EFC ya es su espejo (Pedimento.anexo22_pedimento_id).
Está planeado que el CRM se conecte a Anexo22, y ese día los embarques del CRM se ligarán directo al pedimento
de Anexo22. Por eso el acoplamiento vive todo en una tabla nueva (pedimento_expediente) y no se agrega
ni una columna a pedimento: la tabla se dropea el día de la unión y no queda nada que migrar.
2. Decisiones cerradas — no re-abrir durante la implementación
| Tema | Decisión | Por qué |
|---|---|---|
| Dónde vive el enlace | Tabla nueva pedimento_expediente en EFC. Cero columnas nuevas en pedimento |
La tabla se dropea cuando el CRM se una a Anexo22; una columna obligaría a otra migración |
| Handle del CRM hacia EFC | folio + crm_expediente_id. Nunca pedimento_id ni pedimento_app |
pedimento_app es mutable: se reescribe al completar. Mismo principio que el gateway de Anexo22, que "no guarda referencias de EFC" |
| Llave del provisional | storage_token = CRM-{company_id}-EXP{YYYY}-{MM}-{NNN} |
Empieza con letras → imposible colisionar con ^\d{2}-\d{2}-\d{4}-\d{7}$. Y el company_id evita que dos companies del mismo tenant generen el mismo valor |
| Carpeta de MinIO | Se pasa siempre storage_token a save_document(), no pedimento.pedimento_app |
El token es inmutable → ningún objeto se mueve nunca al completar |
Escrituras sobre Pedimento |
.update() de queryset, siempre |
Ver H1: save() cuesta un sleep(4) síncrono |
| Autenticación del carril | Clon literal de HasAnexo22IntegrationApiKey con secreto propio CRM_INTEGRATION_API_KEY |
Consistencia con el carril de referencia. La deuda (key sin binding a organización) se documenta, no se cierra aquí |
| Modo de entrega | Outbox + Celery, sin HTTP inline en el request | Es como funciona el gateway de Anexo22, y es lo que se pidió: "guarda temporal y reintenta en cola" |
| "Fuente única" | Vía delete_local: el objeto local se borra al confirmar la entrega |
"Solo EFC" es el estado final (eventual), no el inmediato |
| Borrado en el CRM | Desasocia, no destruye en EFC | El gateway de Anexo22 nunca llama al DELETE de EFC. record.Document no tiene vigencia ni purga → la política implícita es conservar |
| Tipos de documento | Conjunto cerrado validado en ambos lados | En el CRM doc_type es texto libre sin validación de backend; un typo crearía basura en el catálogo global de EFC |
| Colisión al completar | 409, no se fusiona | Es dato fiscal: un fallo visible es mejor que colgar documentos del pedimento equivocado |
| Ancla del expediente | La solicitud (crm.service_requests) |
Un expediente por hilo comercial, de RFQ a factura, siguiendo la cadena que el CRM ya tiene |
| Descarga | Proxy con streaming en el CRM | EFC nunca entrega URL de MinIO: reescribir el host de una URL firmada invalida SigV4 |
Nomenclatura fija — se usa literal en todas las fases
Fuente en EFC .................. "APP-CRM"
storage_token .................. CRM-{company_id}-EXP{YYYY}-{MM}-{NNN} ej. CRM-1-EXP2026-08-001
folio del CRM .................. EXP{YYYY}-{MM}-{NNN} ej. EXP2026-08-001
Pedimento.pedimento ............ el folio, sin prefijo ej. EXP2026-08-001
crm_document_ref ............... {TABLA}-{company_id}-{row_id} ej. SHPDOC-1-4471, CRMDOC-1-903
LEGACY-{tabla}-{id} para el backfill
INVOICE-{company_id}-{invoice_id} para el PDF de factura
Prefijo de tipos en EFC ........ "CRM - " ej. "CRM - MBL (Master Bill of Lading)"
Datos dummy — nunca reales
RFC XAXX010101000, pedimento 0000-0000000, patente 0000, aduana 000.
3. El carril de referencia: Anexo22 → EFC
Directriz. El carril CRM → EFC se construye clonando el carril Anexo22 → EFC que ya está en producción, con los nombres cambiados. No es una reinterpretación.
Ruta: C:\Users\USUARIO\Desktop\anexo 22\anexo22\backend\api\v1\modules\pedimentos\pedimento_gateway\
| Archivo | Líneas | Qué se calca |
|---|---|---|
client.py |
269 | EfcClient, EfcClientError(status_code, code, retryable), reintentos, backoff, corte en 4xx, mTLS, is_configured, transport inyectable |
models.py |
124 | Las dos tablas de outbox, los estados, MAX_ATTEMPTS, delete_local |
service.py |
~1670 | Enganche best-effort, entrega, _register_failure, _ya_entregado, ensure-then-upload, ops, reconciliación de huecos |
tasks.py |
171 | Entrega inmediata + barridos + reconciliación |
routes.py |
52 | Tablero de ops: listar, reintentar, métricas |
Y el patrón de folio concurrente-seguro: catalogos/customs_brokers/folios.py +
pedimentos/folio_validacion.py del mismo repo.
La máquina de reintentos, en sus tres capas — esto es lo que se clona
Capa 1, en el cliente HTTP. retries = 2 → 3 intentos. Backoff lineal
time.sleep(0.15 * (attempt + 1)). 2xx éxito · >=500 con intentos restantes reintenta · 4xx NO
reintenta y extrae code/message del cuerpo estructurado de EFC · TimeoutException/NetworkError
reintenta y al agotarse lanza retryable=True · cualquier otra excepción retryable=False. El error lleva
retryable para que el worker decida por el campo y nunca parseando strings, y code para ramificar.
Capa 2, en el worker.
def _register_failure(db, row, exc, retryable):
row.attempts = (row.attempts or 0) + 1
row.last_error = str(exc)[:2000]
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
row.status = STATUS_FAILED
db.commit()
deliver_row nunca lanza: captura EfcClientError y Exception y registra el fallo en la propia fila.
Un fallo no mata al worker ni pierde la intención.
Capa 3, barridos de Celery beat. Cada 120 s para las dos tablas de outbox, 300 s para la
reconciliación de huecos. Leen pending (limit 100, created_at ASC) sin contexto de tenant y
re-despachan una tarea hija por fila con sus headers RLS.
Backoff de intervalo fijo, no exponencial. No hay autoretry_for, retry_backoff ni max_retries en
las tareas: el reintento es el bucle de 0.15/0.30 s del cliente más el barrido de 120 s, hasta
MAX_ATTEMPTS = 8.
Cuatro guardas de idempotencia. (1) _ya_entregado(source_table, source_id, kind) antes de encolar —
EXISTS(status='sent'); su docstring en el original explica el defecto: sin ella, un reintento encolaba otra
entrega que además falla al leer el objeto local porque la primera ya lo borró. (2)
if row.status == STATUS_SENT: return al entrar a entregar. (3) Preguntar a EFC por crm_document_ref antes
de subir. (4) El UniqueConstraint parcial del lado de EFC: una entrega repetida devuelve el documento que ya
existía (200) en vez de crear otro (201).
Ensure-then-upload. Si el upload devuelve 404 expediente_no_encontrado, se crea el provisional y se
reintenta el upload una vez. Resuelve la carrera entre la creación del expediente y la subida.
Corte directo (delete_local). Al confirmar, borra el objeto de MinIO local, envuelto en su propio
try porque "ya está en EFC".
Best-effort en todo el enganche. if not settings.EFC_API_URL: return en cada punto de entrada; el
encolado en try/except con rollback() y return None para que la integración nunca afecte la
operación local; el despacho también best-effort — "si el broker no responde, el sweep la recoge".
4. Hallazgos verificados — las tres primeras corrompen datos fiscales
H1 — .update() obligatorio, nunca save(). En EFC/backend/api/customs/signals/procesamiento.py, el
receptor trigger_celery_task_on_update tiene como única condición if not created: → cualquier save()
sobre un Pedimento ejecuta un sleep(4) síncrono y encola procesamiento espurio.
Anexo22PedimentoIngestView (buscar pedimento_obj.save() en api/customs/views_integrations_anexo22.py)
sí lo hace. Es la única parte de Anexo22 que NO se clona.
H2 — trampa del rango VU 13-26. DocumentType.objects.get_or_create() deja al autoincrement asignar el
id; si cae en 13-26, Document.save() marca vu=True (buscar 13 <= en api/record/models.py) y el
documento entra en los borrados masivos de VU (api/record/views.py, acciones bulk-delete-partidas-vu,
bulk-delete-coves-vu, bulk-delete-edocs-vu) → pérdida de archivo fiscal. Usar siempre
_resolver_document_type de api/record/views_integrations_anexo22.py, que fuerza max(max(id)+1, 27) y
avanza la secuencia con setval(). get_or_create para DocumentType está prohibido.
H3 — el provisional SÍ recibe checklist. En EFC/backend/core/cumplimiento_documental.py, buscar
generales.get(org_id): sin clave_pedimento ni tipo_operacion_id la resolución cae al checklist
TipoChecklist.TODOS. Si la organización tiene uno configurado, cada provisional publica un porcentaje bajo
real en las 8 columnas cumplimiento_* de Pedimento — el modo de fallo que el autor del motor documentó y
quiso evitar (buscar contamina reportes).
H4 — Anexo22 no propaga bajas a EFC hoy, pero Document.pedimento es CASCADE. El campo operacion se
declara en Anexo22PedimentoIngestSerializer y nunca se lee en la vista; el cliente de Anexo22 no tiene
método DELETE. Cuando esa propagación se construya, borrar un pedimento borraría los documentos del CRM en
silencio. Por eso el enlace va con on_delete=PROTECT.
H5 — colisión de folio entre companies. El puente es Organizacion.hub_tenant_slug ↔ Tenant.slug, o
sea tenant → organización 1:1, pero un tenant tiene N companies. Dos companies generando
EXP2026-08-001 colisionarían en unique_together(organizacion, pedimento_app) y el get_or_create
devolvería el provisional de la company A a la B. Por eso el company_id entra en el storage_token.
5. Contrato
5.1 EFC — carril máquina-a-máquina
Header obligatorio en todas: X-Api-Key: <CRM_INTEGRATION_API_KEY>.
GET /api/v1/organization/integrations/crm/organizaciones/?q=texto
POST /api/v1/organization/integrations/crm/organizaciones/resolver/
POST /api/v1/customs/integrations/crm/expedientes/
POST /api/v1/customs/integrations/crm/expedientes/{folio}/completar/
GET /api/v1/customs/integrations/crm/expedientes/{folio}/
POST /api/v1/record/integrations/crm/documentos/
GET /api/v1/record/integrations/crm/documentos/list/
GET /api/v1/record/integrations/crm/documentos/{uuid:pk}/descargar/
DELETE /api/v1/record/integrations/crm/documentos/{uuid:pk}/eliminar/
PUT /api/v1/record/integrations/crm/documentos/{uuid:pk}/reemplazar/
Resolver organización — body A: {"tenant_slug": "temex", "tenant_name": "TEMEX"}; body B (alta
manual): {"nombre": "...", "rfc": "XAXX010101000"}. Respuesta 200/201:
{"id","nombre","rfc","hub_tenant_slug","is_active","created"}.
Crear expediente provisional — body:
{"crm_tenant_slug": "temex", "crm_company_id": 1, "crm_expediente_id": 42,
"folio": "EXP2026-08-001", "storage_token": "CRM-1-EXP2026-08-001"}
201 (nuevo) / 200 (ya existía):
{"status": "created", "vucem": "no_aplica",
"efc": {"pedimento_id": "<uuid>", "pedimento_app": "CRM-1-EXP2026-08-001",
"storage_token": "CRM-1-EXP2026-08-001", "estado": "provisional",
"consultar_vucem": false, "existe_expediente": false}}
Completar — body: {"crm_expediente_id": 42, "pedimento": {"numero_pedimento": "0000001", "aduana": "000", "patente": "0000", "anio": 2026, "clave_documento": "A1", "tipo_operacion": "IMP", "regimen": "IMD", "fecha_inicio": "...", "fecha_final": "...", "fecha_pago": "..."}, "importador": {"rfc": "XAXX010101000", "nombre_razon_social": "..."}, "agente_aduanal": {"rfc_agente": "...", "curp_apoderado": "..."}}. 200:
{"status":"completed","efc":{"pedimento_id","pedimento_app","pedimento_app_anterior","consultar_vucem":false}}
Subir documento — parser_classes = [MultiPartParser], solo multipart, no base64. form-data:
crm_company_id, crm_expediente_id, tipo, crm_document_ref, y file como archivo. 201 (nuevo) / 200
(idempotente) con _crm_doc_to_dict = _doc_to_dict + crm_document_ref.
Formato de error uniforme: {"error": {"code": "...", "message": "..."}}
| Código | error.code |
Cuándo |
|---|---|---|
| 400 | payload_invalido |
serializer inválido |
| 400 | pedimento_app_reservado |
el storage_token matchea ^\d{2}-\d{2}-\d{4}-\d{7}$ |
| 400 | storage_token_invalido |
más de 25 caracteres |
| 400 | efc_pedimento_app_incompleto |
al completar falta aduana/patente/número/año |
| 400 | tipo_invalido, archivo_faltante, extension_no_permitida, archivo_demasiado_grande |
validación de subida |
| 400 | espacio_insuficiente |
cuota de licencia agotada |
| 403 | documento_no_eliminable |
la fuente del documento no es APP-CRM |
| 403 | (sin cuerpo) | API key ausente, vacía o incorrecta |
| 404 | expediente_no_encontrado |
dispara el ensure-then-upload del cliente |
| 404 | documento_no_encontrado |
incluye el caso cross-organización: 404, no 403 |
| 409 | organizacion_no_utilizable |
is_active/is_verified en False |
| 409 | licencia_sin_espacio |
licencia en 0 GB |
| 409 | expediente_ya_completado |
el pedimento_app ya no empieza con CRM- |
| 409 | pedimento_real_ya_existe |
+ {pedimento_id, pedimento_app, total_documentos} del existente |
| 409 | conflicto |
carrera perdida en el IntegrityError |
| 502 | error_storage |
save_document devolvió falsy |
5.2 CRM — API de usuario
GET /api/v1/crm/expedientes?company_id=1
GET /api/v1/crm/expedientes/{id}?company_id=1
POST /api/v1/crm/expedientes/ensure?company_id=1
POST /api/v1/crm/expedientes/{expediente_id}/documentos?company_id=1
multipart: file · form: doc_type, name?, target?, target_owner_id?
GET /api/v1/crm/expedientes/{expediente_id}/documentos/{document_id}/archivo?company_id=1
GET /api/v1/crm/expediente-gateway/outbox?tipo=&status=&limit=&company_id=1
POST /api/v1/crm/expediente-gateway/outbox/{outbox_id}/retry?company_id=1
GET /api/v1/crm/expediente-gateway/metrics?company_id=1
Todos con company_id: int = Query(...) obligatorio y current_user: dict = Depends(get_current_user), como
el resto del CRM.
POST documentos → 201 con {expediente_id, folio, document_id, doc_type, name, content_type, size_bytes, efc_sync_state, efc_document_ref}. Sin file_key ni file_url. 404 si el expediente no existe en ese
tenant/company; 422 por doc_type, extensión o tamaño.
GET archivo → StreamingResponse con el Content-Type y el Content-Disposition de EFC. 404 si el
documento no pertenece a ese tenant/company; 502 si EFC falla, salvo un 404 de EFC que pasa como 404.
retry → {"status":"requeued","id":<id>}; si la fila no existe para ese tenant/company → 404 con
mensaje específico, no 200: es contrato con el frontend. metrics → {"pending","sent","failed"}.
Formato del folio: EXP{YYYY}-{MM}-{NNN}, consecutivo por (tenant, company, mes), que reinicia cada
mes. Al pasar de 999 crece a 4 dígitos.
6. Fases
Cada fase es un bloque coherente y verificable. Las dependencias son de código, no de reloj.
| # | Repo | Alcance | Depende de |
|---|---|---|---|
| 1 | EFC backend |
Credencial X-Api-Key + resolver de organización |
— |
| 2 | EFC backend |
Tabla pedimento_expediente + provisional + completado |
1 |
| 3 | EFC backend |
Endpoints de documentos + crm_document_ref |
1, 2 |
| 4 | EFC backend |
Anti-contaminación: cumplimiento + grid | 2 |
| 5 | CRM backend |
crm.expedientes + generador de folio |
— |
| 6 | CRM backend |
crm/expediente_gateway — clon del gateway de Anexo22 |
5 |
| 7 | CRM back+front | Subida de un paso, proxy de descarga, UI | 6 |
Si la fase 1 no queda mergeable, las fases 2, 3 y 4 no se empiezan y se registran como no ejecutadas con
el motivo. Si la 2 no queda mergeable, tampoco la 3 ni la 4. La 4 no depende de la 3. Las fases 5-7 son de
otro repo y no dependen de las de EFC para compilar: el gateway se desarrolla contra httpx.MockTransport
sin EFC arriba. La 7 sí necesita la 6.
Colisión de archivos entre fases (para resolver el conflicto conservando los dos bloques al mergear):
las fases 1 y 3 tocan EFC/backend/config/settings.py; las fases 2 y 4 usan
EFC/backend/api/customs/models_crm.py; las fases 5, 6 y 7 tocan CRM/backend/tests/conftest.py y
api/v1/modules/crm/router.py. Las fases 3 y 4 no comparten un solo archivo.
7. Compuertas previas — sin estas tres verdes, el carril no se habilita
No son código: son verificaciones que van al reporte.
- Licencia.
Licencia.almacenamientoen GB de la organización EFC destino. La licencia"Hub SSO Default"tiene 0 GB (api/organization/views_integrations.py, buscar elget_or_createde la licencia) →Document.save()lanzaValueErrory falla el 100% de las subidas. - Visibilidad.
Organizacion.is_activeyis_verifiedenTrue. Conis_verified=Falselos cuatro mixins debackend/mixins/filtrado_organizacion.py(buscaris_verified) hacen la organización invisible incluso para superusuarios. - Tamaño. Hoy hay tres números desalineados: CRM 25 MB (
crm/uploads/routes.py,MAX_UPLOAD_BYTES), nginx de producciónclient_max_body_size 100M(fuera de git), yDocument.sizeesPositiveIntegerField(~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.pyde la misma app, no el de MVE:_asegurar_organizacion_visible_mveenciende ademásapply_auto_download=Trueycredenciales_desde_mve=True, que en el CRM no aplican.
_org_to_dict y _ensure_efc_organization se importan (de organization/views_integrations.py y
cuser/sso_views.py), no se reescriben.
8.2 Archivos a modificar
| Archivo | Qué cambia | Ancla de texto |
|---|---|---|
core/permissions.py |
Añadir HasCrmIntegrationApiKey |
class HasAnexo22IntegrationApiKey — insertar después de esa clase completa |
config/settings.py |
CRM_INTEGRATION_API_KEY = os.getenv('CRM_INTEGRATION_API_KEY', '') |
ANEXO22_INTEGRATION_API_KEY |
.env.example (backend y raíz) |
Declarar CRM_INTEGRATION_API_KEY= vacía |
ANEXO22_INTEGRATION_API_KEY |
api/organization/urls.py |
2 paths explícitos, nunca en el router | integrations/anexo22/organizaciones |
class HasCrmIntegrationApiKey(permissions.BasePermission):
"""Autenticación del carril CRM Agentes de Carga -> EFC (expedientes provisionales + documentos).
Carril propio con su propio secreto: no se reusa el de MVE ni el de Anexo22 porque una key
compartida hace imposible revocar un solo consumidor.
INTERINA, igual que las de MVE y Anexo22: la key NO está ligada a una organización — el
organizacion_id lo elige el llamador, así que una key da acceso a TODAS las organizaciones.
Endurecer con require_permission('integration.crm_replicate') + mTLS terminado en nginx antes de
producción. El scaffolding de mTLS ya existe del lado del cliente (EFC_MTLS_*), así que activarlo
es configuración, no código.
"""
message = 'API key inválida o no configurada.'
def has_permission(self, request, view):
from django.conf import settings
expected = getattr(settings, 'CRM_INTEGRATION_API_KEY', '') or ''
if not expected:
# Fail-closed: sin secreto configurado no se atiende a nadie.
return False
provided = request.headers.get('X-Api-Key', '') or ''
return hmac.compare_digest(provided, expected)
Migración: no agrega migración.
9. Fase 2 — EFC: tabla de enlace, provisional y completado
9.1 Archivos nuevos
api/customs/models_crm.py — precedente de archivo de modelos aparte: api/record/models_descargas.py.
class PedimentoExpediente(models.Model):
"""Enlace TEMPORAL entre un expediente del CRM de agentes de carga y su pedimento en EFC.
ESTA TABLA ESTÁ DESTINADA A DESAPARECER. Existe solo mientras el CRM no esté conectado a
Anexo22 —el sistema centralizado de pedimentos del que EFC ya es espejo vía
Pedimento.anexo22_pedimento_id—. Cuando esa unión ocurra, Anexo22 creará el pedimento real, EFC lo
replicará por su carril, este enlace repuntará `pedimento` y finalmente la tabla se dropea. Por eso
el acoplamiento vive AQUÍ y no como columnas en `pedimento`: una columna en `pedimento` obligaría a
otra migración el día del retiro.
`pedimento` va con PROTECT y no con CASCADE: `record.Document.pedimento` es CASCADE, así que borrar
el pedimento borraría los documentos que el CRM guardó. Hoy Anexo22 no propaga bajas a EFC (el campo
`operacion` de su serializer de ingesta se declara y nunca se lee), pero cuando esa propagación se
construya, un borrado silencioso se llevaría archivo fiscal. PROTECT hace que falle ruidosamente y
obliga a repuntar o soltar el enlace primero, que es el orden correcto.
`storage_token` es INMUTABLE: es la carpeta de MinIO. El carril del CRM lo pasa a
storage_service.save_document() en lugar de `pedimento.pedimento_app` —que sí cambia al completar—,
de modo que todos los objetos de un expediente viven bajo un solo prefijo para siempre y ninguno
necesita moverse nunca.
"""
pedimento = models.ForeignKey('customs.Pedimento', on_delete=models.PROTECT,
related_name='enlace_crm')
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE,
related_name='enlaces_crm')
crm_expediente_id = models.IntegerField()
folio = models.CharField(max_length=20)
storage_token = models.CharField(max_length=25)
crm_tenant_slug = models.CharField(max_length=100, blank=True, default='')
crm_company_id = models.IntegerField()
estado = models.CharField(max_length=20, default='provisional') # provisional | completado
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'pedimento_expediente'
constraints = [
models.UniqueConstraint(fields=['organizacion', 'crm_company_id', 'crm_expediente_id'],
name='uq_crm_link_org_company_exp'),
models.UniqueConstraint(fields=['organizacion', 'folio'], name='uq_crm_link_org_folio'),
models.UniqueConstraint(fields=['pedimento'], name='uq_crm_link_pedimento'),
]
api/customs/views_integrations_crm.py — CrmExpedienteProvisionalView, CrmExpedienteCompletarView,
CrmExpedienteDetalleView.
Se importan de api/customs/views_integrations.py, no se reescriben: _err,
_construir_pedimento_app, _ImportadorDataSerializer, _AgenteAduanalDataSerializer,
_PedimentoDataSerializer.
_construir_pedimento_appse importa. Tiene tres implementaciones en la casa que deben coincidir (ésta, la del alta manual enapi/customs/views.py, y la deapi/datastage/tasks.py). Reimplementarla aquí sería la cuarta.
fecha_pago no existe en _PedimentoDataSerializer; se agrega por subclase para no tocar el serializer
que usan MVE y Anexo22:
class _PedimentoCrmDataSerializer(_PedimentoDataSerializer):
fecha_pago = serializers.DateField(required=False, allow_null=True)
Guarda de reserva, antes de cualquier escritura:
_RE_PEDIMENTO_REAL = re.compile(r'^\d{2}-\d{2}-\d{4}-\d{7}$')
# El carril del CRM NO PUEDE crear ni pisar un pedimento real por ningún bug. La llave real es todo
# dígitos con tres guiones; el provisional empieza con letras. Se rechaza explícitamente en vez de
# confiar en que el CRM mande bien el prefijo.
if _RE_PEDIMENTO_REAL.match(storage_token):
return _err("pedimento_app_reservado", "...", status.HTTP_400_BAD_REQUEST)
Creación del provisional:
with transaction.atomic():
pedimento_obj, created = Pedimento.objects.get_or_create(
organizacion=organizacion,
pedimento_app=storage_token,
defaults={
"pedimento": folio[:20],
"numero_operacion": folio[:20],
# OBLIGATORIO en False: trigger_celery_task_on_create
# (api/customs/signals/procesamiento.py) retorna temprano si no está en True, así que
# crear el provisional no dispara el pipeline VUCEM. El CRM no manda pedimentos a la
# Ventanilla; EFC solo resguarda sus documentos.
"consultar_vucem": False,
# Cinturón: si algún save() futuro sobre esta fila disparara
# trigger_celery_task_on_update, con estos dos en cero no encola remesa ni partida.
"remesas": False,
"numero_partidas": 0,
},
)
PedimentoExpediente.objects.get_or_create(
organizacion=organizacion, crm_company_id=..., crm_expediente_id=...,
defaults={"pedimento": pedimento_obj, "folio": folio,
"storage_token": storage_token, "crm_tenant_slug": ...,
"estado": "provisional"},
)
Si created es False, NO se hace pedimento_obj.save(). Cualquier ajuste va con
Pedimento.objects.filter(pk=...).update(...).
Completado:
def _completar_pedimento_provisional(enlace, *, campos: dict, pedimento_app_nuevo: str):
"""Completa un provisional del carril CRM con la data aduanera real.
Escribe SIEMPRE con .update() de queryset y NUNCA con .save(): el receptor
trigger_celery_task_on_update (api/customs/signals/procesamiento.py) tiene como única condición
`if not created:`, así que un save() ejecuta un sleep(4) SÍNCRONO dentro del request y encola
procesamiento espurio. .update() no dispara señales.
Los documentos ya subidos NO se tocan: la FK Document.pedimento no cambia. Y los objetos de MinIO
NO se mueven, porque el carril del CRM guarda bajo `storage_token`, que es inmutable, y no bajo
`pedimento_app`, que es lo que acaba de cambiar. Mover objetos exigiría una API de copia que
storage_service no tiene, y un fallo a medias dejaría filas de Document apuntando a keys
inexistentes: peor que un prefijo viejo.
Devuelve (ok, codigo_error).
"""
try:
with transaction.atomic():
Pedimento.objects.filter(pk=enlace.pedimento_id).update(
pedimento_app=pedimento_app_nuevo, **campos)
enlace.estado = 'completado'
enlace.save(update_fields=['estado', 'updated_at'])
except IntegrityError:
# unique_together (organizacion, pedimento_app): ese pedimento REAL ya existe en EFC
# (datastage/MVE/Anexo22 lo sembraron). Mover los documentos del provisional al real es una
# fusión de datos fiscales y NO se hace en silencio.
return False, "pedimento_real_ya_existe"
return True, None
Después del .update(): llamar _recalcular_cumplimiento(enlace.pedimento_id) (importado de
api/record/views.py) envuelto en try/except. Es necesario aquí: el checklist aplicable depende de
clave_pedimento y tipo_operacion, que acaban de aparecer, así que la proyección cumplimiento_* quedó
obsoleta.
Bitácora igual que Anexo22 (registrar_actividad / construir_cambios) dentro de try/except: pass con el
comentario "la bitácora nunca debe interrumpir la ingesta".
9.2 Archivos a modificar
| Archivo | Qué cambia | Ancla de texto |
|---|---|---|
api/customs/models.py |
Import de models_crm para que Django registre el modelo |
fin del archivo |
api/customs/urls.py |
3 paths integrations/crm/expedientes/... |
integrations/anexo22/pedimento |
Migración: agrega migración de customs. Se genera con python manage.py makemigrations customs y se
deja sin aplicar. Nombre esperado 0027_pedimentoexpediente. Verificar el número real con
ls api/customs/migrations/, no asumirlo, y comprobar con makemigrations --check --dry-run que quede una
sola hoja.
10. Fase 3 — EFC: endpoints de documentos
10.1 Archivos nuevos
api/record/views_integrations_crm.py — cinco vistas + _crm_doc_to_dict + _resolver_expediente +
FUENTE_CRM + TIPOS_DOCUMENTO_CRM.
Se importan, no se copian:
from api.record.views_integrations import _doc_to_dict, _err, _resolver_pedimento
from api.record.views_integrations_anexo22 import _resolver_document_type
from api.record.views import _asignar_tipo_expediente, _recalcular_cumplimiento
_doc_to_dict no se modifica. Su propio comentario advierte que es el punto de serialización de dos
integraciones que viven en otros repos y que los cambios deben ser aditivos. Se define _crm_doc_to_dict(doc)
que lo llama y agrega crm_document_ref.
FUENTE_CRM = "APP-CRM"
# Conjunto CERRADO, a diferencia del carril Anexo22, que manda el tipo libre y deja que EFC lo resuelva
# por nombre. Ahí funciona porque sus 9 tipos son constantes de código; en el CRM `doc_type` es
# String(60)/String(30) SIN validación de backend —los catálogos viven solo en TypeScript— así que un
# typo crearía un DocumentType basura en el catálogo GLOBAL de EFC. El prefijo "CRM - " garantiza además
# que _resolver_document_type nunca choque con un nombre de MVE o de Anexo22, y que el id caiga en >= 27.
TIPOS_DOCUMENTO_CRM = {
# --- crm.documents (DOC_TYPES de frontend/src/lib/api/crm/format.ts) ---
"constancia_fiscal": ("CRM - Constancia de Situación Fiscal", "Documento de cliente/proveedor del CRM"),
"acta_constitutiva": ("CRM - Acta Constitutiva", "..."),
"identificacion": ("CRM - Identificación Oficial", "..."),
"comprobante_domicilio": ("CRM - Comprobante de Domicilio", "..."),
"contrato": ("CRM - Contrato", "..."),
"presentacion": ("CRM - Presentación Comercial", "..."),
"certificacion": ("CRM - Certificación", "..."),
"licencia": ("CRM - Licencia", "..."),
"convenio": ("CRM - Convenio", "..."),
"tarifario": ("CRM - Tarifario", "..."),
# --- ops.shipment_documents (SHIPMENT_DOC_TYPES del mismo archivo) ---
"MBL": ("CRM - MBL (Master Bill of Lading)", "Documento de transporte del embarque"),
"HBL": ("CRM - HBL (House Bill of Lading)", "..."),
"MAWB": ("CRM - MAWB (Master Air Waybill)", "..."),
"HAWB": ("CRM - HAWB (House Air Waybill)", "..."),
"CMR": ("CRM - CMR (Carta Porte Internacional)", "..."),
"factura_comercial": ("CRM - Factura Comercial", "..."),
"packing_list": ("CRM - Packing List", "..."),
"carta_encomienda": ("CRM - Carta Encomienda", "..."),
"carta_garantia": ("CRM - Carta Garantía", "..."),
"certificado_permiso": ("CRM - Certificado / Permiso", "..."),
# --- fin.invoices ---
"factura_venta": ("CRM - Factura de venta (PDF)", "PDF de factura generado por el CRM"),
# --- 'otro' existe en AMBAS listas del CRM y significa lo mismo: una sola entrada ---
"otro": ("CRM - Otro", "Documento sin tipo específico enviado desde el CRM"),
}
Orden no negociable del POST — las doce reglas de EFC, en el orden en que se aplican:
- Validar
tipocontraTIPOS_DOCUMENTO_CRM→ 400tipo_invalido. Validar extensión contra allowlist (.pdf .xml .png .jpg .jpeg .json .txt .zip .docx .xlsx) yfile.sizecontraCRM_MAX_UPLOAD_BYTES. EFC hoy no tiene ni allowlist ni tope propio; el único freno real es la cuota de licencia. _resolver_expediente(organizacion, crm_company_id, crm_expediente_id)→ 404expediente_no_encontrado._resolver_document_type(nombre, descripcion)— nuncaget_or_create(H2).Fuente.objects.get_or_create(nombre=FUENTE_CRM, ...).- 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. - Cuota:
UsoAlmacenamiento.objects.get_or_create(...);uso.espacio_utilizado + file.size > organizacion.licencia.almacenamiento * 1024**3→ 400espacio_insuficiente. storage_service.save_document(file=file, organizacion_id=org.id, pedimento_app=enlace.storage_token, metadata={"source": "crm", "crm_document_ref": ref}). Falsy → 502error_storage.Se pasa
enlace.storage_token, NOpedimento.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.Document.objects.create(..., nombre_original=file.name, size=file.size, extension=..., crm_document_ref=ref), envuelto en:except ValueError→storage_service.delete_file(ruta)+ 400espacio_insuficiente.Document.save()revalida la cuota en su propia transacción y lanzaValueError; sin limpiar la ruta queda un objeto huérfano cobrando espacio.except IntegrityError→delete_file(ruta), releer el que ganó la carrera → 200, o 409conflicto.
_asignar_tipo_expediente(documento, organizacion.id). DevolveráFalsepara 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_nomenclaturaNO se llama — el carril Anexo22 tampoco lo hace. Si algún día se llama, recibefile.namey nunca la ruta guardada: eluuid4().hex[:8]que inyecta_generate_filenamepuede contener por azar una nomenclatura corta de letras hex._recalcular_cumplimiento(enlace.pedimento_id)en try/except — "el recálculo es una consecuencia de la operación, no parte de ella".- 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.pyborra antes de subir y eso pierde archivo fiscal si la subida falla. - Preservar
vu,partida_id,cove_id,edocument_idcon un.update()posterior sisave()los movió:Document.save()recalculavuy re-resuelve las FKs de sub-entidad por heurística de nombre víaresolver_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)deAnexo22DocumentoDescargarView:atexitcorre al terminar el proceso, y bajo un worker de vida larga los temporales se acumulan hasta llenar disco.
No se clona Anexo22DocumentoAsignarIdView (el PATCH .../anexo22-id/): era una pasada de relleno
histórica y el CRM no tiene nada que rellenar.
10.2 Archivos a modificar
| Archivo | Qué cambia | Ancla de texto |
|---|---|---|
api/record/models.py |
Campo crm_document_ref + UniqueConstraint parcial en Meta.constraints |
anexo22_document_id para el campo; document_anexo22_id_tipo_uniq para el constraint |
api/record/urls.py |
5 paths integrations/crm/documentos/... |
integrations/anexo22/documentos |
config/settings.py |
CRM_MAX_UPLOAD_BYTES, CRM_ALLOWED_EXTENSIONS |
CRM_INTEGRATION_API_KEY (lo dejó la fase 1) |
crm_document_ref = models.CharField(
max_length=64, null=True, blank=True, db_index=False,
help_text="Handle del documento de origen en el CRM de agentes de carga",
)
# Es TEXTO y no un entero como anexo22_document_id porque el CRM tiene DOS tablas de documentos con
# secuencias independientes —crm.documents y ops.shipment_documents— más fin.invoices: un solo entero
# colisionaría entre ellas (crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten).
# Formato: {TABLA}-{company_id}-{row_id}.
#
# Es la CUARTA capa de idempotencia del carril, y la única que la base garantiza: la entrega del CRM la
# hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde— duplicaría
# el documento sin esto.
#
# db_index=False EXPLÍCITO: la tabla ronda los 5M de renglones. El índice único lo crea una migración
# aparte con CREATE INDEX CONCURRENTLY.
models.UniqueConstraint(
fields=['organizacion', 'crm_document_ref'],
condition=models.Q(crm_document_ref__isnull=False),
name='document_crm_ref_uniq',
),
# Parcial como uq_organizacion_hub_tenant_slug_nonblank: las filas que no vienen del CRM comparten NULL
# y no deben colisionar entre sí. Sobre (organizacion, ref) y no sobre la ref sola porque toda lectura
# de este carril ya filtra por organizacion.
Migración: agrega dos migraciones de record, ambas generadas y sin aplicar:
0015_document_crm_ref— solo elAddFieldcondb_index=Falsey sin el constraint. En PostgreSQL ≥ 11 unADD COLUMNnullable sin default volátil es metadata-only: no reescribe los 5M de renglones.0016_document_crm_ref_index—atomic = False, conSeparateDatabaseAndState:state_operations=[AddConstraint(...)]ydatabase_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.pyy0005_document_subentidad_idx.py, incluido elIF NOT EXISTSpara 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 condb_index=Truey elAddConstrainten la misma migración bloqueante sobre la tabla de 5M.El reporte debe decir que la
0016hay que medirla en dev antes de producción y que aplicarla es ventana de mantenimiento humana, no trabajo de la corrida.
11. Fase 4 — EFC: anti-contaminación de los provisionales
Todo derivado de pedimento_expediente, sin columnas nuevas en Pedimento. Cuando la tabla se dropee,
estos tres cambios se retiran y no queda migración pendiente.
from django.db.models import Exists, OuterRef
from api.customs.models_crm import PedimentoExpediente
_ES_PROVISIONAL = Exists(
PedimentoExpediente.objects.filter(pedimento=OuterRef('pk'), estado='provisional')
)
| Archivo | Qué cambia | Ancla de texto |
|---|---|---|
api/customs/cumplimiento.py |
Si el pedimento es provisional, dejar las 8 columnas cumplimiento_* en NULL y salir |
la función que hace el .update() de las columnas cumplimiento_ |
api/customs/views.py |
ViewSetPedimento.get_queryset(): anotar y excluir provisionales salvo ?incluir_provisionales=true |
class ViewSetPedimento → su get_queryset |
api/customs/views.py |
PedimentoFilter.Meta.fields += es_provisional, crm_expediente_folio |
class PedimentoFilter |
api/customs/serializers.py |
PedimentoSerializer += los dos campos derivados (aditivo) |
class PedimentoSerializer |
Justificación para el docstring de la guarda de cumplimiento:
Un expediente provisional del CRM no tiene clave_pedimento ni tipo_operacion, así que no hay checklist
ADUANAL aplicable: publicar un porcentaje sería medir el vacío. porcentaje=None ya es el contrato del
motor para "nada aplicable" (core/cumplimiento_documental.py), así que todo lo de aguas abajo es
null-safe. Sin esta guarda el provisional cae al checklist TipoChecklist.TODOS de la organización y
emite un porcentaje bajo REAL que arrastra los reportes.
Un solo cambio cubre cuatro endpoints: verificado que list, export_excel, activo_fijo y
export_excel_activo_fijo todos entran por self.get_queryset(). Importa porque
PedimentoPagination.page_size es None y paginate_queryset devuelve None sin page_size, así que
/pedimentos/ devuelve TODOS los pedimentos.
NO aplicar la exclusión a DescargaExpedienteViewSet ni a los tres bulk-delete-*-vu: reciben listas
explícitas de ids y filtrar ahí sería incorrecto.
Los dos campos nuevos van fuera de ordering_fields y de search_fields, igual que las columnas
cumplimiento_*.
Migración: no agrega migración.
12. Fase 5 — CRM: entidad expediente y generador de folio
12.1 Archivos nuevos
Módulo api/v1/modules/crm/expedientes/ con la convención rígida del repo: __init__.py, models.py,
dto.py (Pydantic v2, Base/Create/Update/Response, ConfigDict(from_attributes=True)), service.py
(funciones libres que reciben db, tenant_id, company_id), routes.py (APIRouter sin prefijo propio),
folio.py, doc_types.py.
crm.expedientes (Base, TenantScopedMixin, TimestampMixin):
| Campo | Tipo | Nota |
|---|---|---|
id |
Integer PK | |
folio |
String(20) NOT NULL | EXP2026-08-001 |
period_year / period_month / sequence |
Integer NOT NULL | folio descompuesto → el contador es un constraint real, no un parse de string |
service_request_id |
FK crm.service_requests.id nullable index |
el ancla |
account_id |
FK crm.accounts.id nullable |
|
status |
String(20) NOT NULL server_default 'abierto' |
abierto | completado | cerrado |
efc_organizacion_id / efc_pedimento_id |
String(36) nullable | espejo, no handle |
efc_storage_token |
String(25) nullable | inmutable una vez asignado |
efc_link_state |
String(20) NOT NULL server_default 'PENDING' |
PENDING | LINKED | FAILED |
efc_error_code String(60) / efc_error_detail Text |
nullable | diagnóstico visible en la ficha, sin ir a logs |
patente, aduana, numero_pedimento, anio, clave_pedimento, regimen, fecha_pago, rfc_importador, rfc_agente_aduanal |
data aduanera real, se llena al completar | |
created_by / updated_by |
String(64) |
Constraints: uq_crm_expedientes_folio UNIQUE (tenant_id, company_id, folio) y
uq_crm_expedientes_periodo_seq UNIQUE (tenant_id, company_id, period_year, period_month, sequence).
Es la red de seguridad: si el contador se corrompe, un folio duplicado falla ruidosamente en vez de mezclar dos expedientes.
class ExpedienteFolioCounter(Base) — expediente_folio_counters, schema crm, PK compuesta
(tenant_id, company_id, period) con period String(7) ("2026-08"), y last_seq Integer NOT NULL
default 0.
folio.py:
def next_folio(db: Session, tenant_id: int, company_id: int, on: date | None = None) -> tuple[str, int, int, int]:
"""Reserva el siguiente consecutivo del mes y devuelve (folio, year, month, sequence).
Una sola sentencia atómica, sin read-modify-write: el INSERT ... ON CONFLICT DO UPDATE serializa
sobre la fila de ese (tenant, company, mes) y devuelve el valor ya incrementado. Un
`SELECT max(sequence)+1` es exactamente la carrera que hay que evitar, y un SELECT ... FOR UPDATE
también sirve pero son dos viajes.
NO hace commit: opera sobre la sesión que recibe, para que un fallo posterior en la creación del
expediente pueda hacer rollback sin quemar el folio. Es la misma disciplina del asignador de folios
de Anexo22 (catalogos/customs_brokers/folios.py): validar antes de tocar el contador, no commitear
dentro del asignador, y fallar cerrado ante ambigüedad.
Un rollback deja HUECO en la secuencia. Los huecos son aceptables; los duplicados no.
"""
INSERT INTO crm.expediente_folio_counters (tenant_id, company_id, period, last_seq)
VALUES (:t, :c, :p, 1)
ON CONFLICT (tenant_id, company_id, period)
DO UPDATE SET last_seq = crm.expediente_folio_counters.last_seq + 1
RETURNING last_seq
service.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.pylas tablas nuevas no entran aBase.metadatay 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.pycorrealembic upgrade headal arrancar, así que en este repo no hay paso manual — pero tampoco se correupgradea 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_tablees un añadido necesario sobre el original. El CRM tiene dos tablas de documentos con secuencias independientes, así quesource_idsolo es ambiguo. Es el mismo problema que Anexo22 resolvió con_DESTINO_POR_KIND, y su comentario dice exactamente qué pasa si se ignora: unUPDATEcon 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_locales el mecanismo de "EFC es la fuente única". El archivo se guarda en el MinIO del CRM (durable), la fila del outbox referencia sus3_key, y al confirmar la entrega se borra la copia local. "Solo EFC" es el estado FINAL (eventual), no el inmediato.
13.3 crm/expediente_gateway/service.py
Tabla de equivalencias que va en el docstring del módulo, para que quien conozca un carril lea el otro:
| Anexo22 | CRM |
|---|---|
replicate_pedimento_best_effort |
replicate_expediente_best_effort |
_enqueue_pedimento_outbox |
_enqueue_expediente_outbox |
_dispatch_delivery |
igual |
deliver_row / _deliver_pedimento |
deliver_row / _deliver_expediente |
_register_failure |
idéntico |
_ya_entregado(source_id, kind) |
_ya_entregado(source_table, source_id, kind) |
deliver_file_row |
idéntico, incluidos ensure-then-upload y delete_local |
_register_file_failure |
idéntico |
_resolve_org_id + _org_id_cache |
igual — dict módulo-global, por worker, sin invalidación |
list_outbox / retry_outbox_row / outbox_metrics |
igual, para las dos tablas |
find_pedimento_gaps |
find_expediente_gaps |
Ensure-then-upload, literal del original:
try:
resp = client.upload_documento(...)
except EfcClientError as exc:
if exc.status_code == 404 and exc.code == "expediente_no_encontrado":
# La creación del provisional puede venir en camino: se asegura y se reintenta UNA vez.
client.ingest_expediente({...})
resp = client.upload_documento(...)
else:
raise
Corte directo, con su try propio:
if row.delete_local:
try:
from core.storage_s3 import delete_object_if_exists
delete_object_if_exists(row.s3_key)
except Exception:
# Ya está en EFC: no poder borrar la copia local no invalida la entrega.
logger.warning(...)
Simplificación legítima por ser mono-base: en Anexo22 el outbox se commitea aparte del pedimento (dos
bases distintas), y ese doble-commit es justo lo que obligó a inventar sweep_pedimento_gaps. El CRM es
mono-base, así que la fila del outbox va en la misma transacción que el expediente o el documento.
find_expediente_gaps se conserva igual —cubre lo creado antes de activar la integración y cualquier crash—
pero deja de ser el parche de una ventana estructural. Dejarlo escrito en el docstring, o el siguiente que
lo lea va a creer que es redundante.
13.4 crm/expediente_gateway/tasks.py
| Tarea | Beat |
|---|---|
expediente_gateway.deliver_outbox_row |
— |
expediente_gateway.sweep_outbox |
120 s |
expediente_gateway.deliver_file_outbox_row |
— |
expediente_gateway.sweep_file_outbox |
120 s |
expediente_gateway.sweep_expediente_gaps |
300 s, no-op si not settings.EFC_API_URL |
Trampa de RLS — el detalle que más fácil se pasa por alto. core/celery_app.py del CRM materializa el
contexto desde los headers rls_tenant_id/rls_company_id, que solo pone
api/v1/modules/core/tasks_tracking/dispatch.py::track_and_dispatch. Por tanto:
- Las tareas por fila deben despacharse con esos headers. Conviene ir por
track_and_dispatch, que además queda registrado en el TaskTracker. - Los barridos corren sin contexto de tenant: leen los ids pendientes con una sesión sin scope y despachan una tarea hija por fila con sus propios headers. Si un barrido abriera una sesión con scope e iterara, o no ve nada o se salta el aislamiento.
LicenseValidationMiddlewarees fail-closed contra el Hub pero no corre en el worker: el outbox no debe asumir contexto de licencia.
13.5 crm/expediente_gateway/routes.py
APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"]), bajo /v1/crm. Auth normal del CRM,
tenant del token, company_id del query. Los tres endpoints del contrato §5.2.
13.6 Archivos a modificar
| Archivo | Qué cambia | Ancla de texto |
|---|---|---|
core/config.py |
Las 8 variables de EFC | el bloque de S3_ENDPOINT_URL — insertar después |
core/celery_app.py |
include del módulo + 3 entradas de beat_schedule |
el include= y el beat_schedule |
api/v1/modules/crm/router.py |
include_router del gateway |
el include_router que dejó la fase 5 |
.env.example |
Las 8 variables, todas vacías | fin del archivo |
docker-compose.prod.yml |
Las 8 variables en api, worker y beat | los environment: de los tres servicios |
tests/conftest.py |
Import de los modelos del gateway | los imports que dejó la fase 5 |
Se reusan los nombres de variable que Anexo22 ya definió, no se inventan otros:
EFC_API_URL: str = ""
EFC_API_KEY: str = "" # == CRM_INTEGRATION_API_KEY del lado de EFC
EFC_API_VERIFY_SSL: bool = True
EFC_API_TIMEOUT_MS: int = 8000 # metadatos: resolver, ingest, completar
EFC_UPLOAD_TIMEOUT_MS: int = 55000 # subidas
EFC_MTLS_CA_PATH: str = ""
EFC_MTLS_CERT_PATH: str = ""
EFC_MTLS_KEY_PATH: str = ""
EFC_API_URL pasa por el field_validator(mode="before") que ya sanea las otras URLs del repo.
EFC_UPLOAD_TIMEOUT_MSes 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 delproxy_read_timeoutdel 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 mismocrm_document_refes 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:
POST /v1/crm/uploadshaceawait file.read()completo antes de validar el tamaño → un archivo de 2 GB se bufferiza en RAM antes del 422.- No hay allowlist de MIME ni de extensión, a diferencia del avatar y del centro de ayuda, que sí la tienen.
GET /v1/crm/uploads/urlvalida solo que la key empiece contenants/{tid}/companies/{cid}/, lo que permite firmar una URL para cualquier objeto de esa company —fin-invoices/,certificates/,imports/csv/— con solocrm.access.
Secuencia del POST de documentos:
- Escribir en streaming al MinIO del CRM contando bytes y abortando al superar el tope. Nunca
await file.read(). - Validar extensión/MIME contra la allowlist y
doc_typecontradoc_types.EFC_DOC_TYPES→ 422. - Resolver el expediente (tenant/company/
deleted_at IS NULL) → 404. efc_document_ref = f"{TABLA}-{company_id}-{row_id}"una vez creada la fila.- En una transacción: crear la fila del documento (
efc_sync_state='PENDING',file_key= la key local,content_sha256) y la fila decrm.efc_file_outboxcondelete_local=True, previo_ya_entregado. _dispatch_file_delivery(...)best-effort y responder 201.
Proxy de descarga:
# EFC nunca entrega una URL de MinIO: reescribir el host de una URL ya firmada invalida SigV4
# (documentado en api/customs/serializers.py de EFC). Así que el CRM hace de segundo proxy.
#
# Dos guardas, las mismas que los tests del proxy de Anexo22 fijan:
# 1. Validar la pertenencia ANTES de tocar EFC. Sin esto, un document_id coincidente leería el
# expediente de otro tenant — y peor: el organizacion_id se deriva del expediente, así que el proxy
# iría a preguntar a la organización de otro cliente.
# 2. Mandar el organizacion_id del expediente, para que la verificación de EFC también dispare.
#
# El AsyncClient se crea DENTRO del generador y se cierra en finally. Si se creara en un `async with`
# que cierra antes de que empiece el streaming, la respuesta muere a medias.
async def _iter_upstream(...):
client = httpx.AsyncClient(...)
try:
async with client.stream("GET", url, headers=...) as upstream:
...
async for chunk in upstream.aiter_bytes():
yield chunk
finally:
await client.aclose()
Traducción de errores: 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.Documenten 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 usarfetch→blob→URL.createObjectURL, con elapi.getBlobque ya existe ensrc/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.tsya tieneCsvFormDataUploadOptionscononProgress, perouploads.tsusaapi.requestplano. 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.
- Poner
CRM_INTEGRATION_API_KEYen el.envde EFC yEFC_API_URL/EFC_API_KEYen el del CRM. Reiniciar api, worker y beat de ambos. curlal resolver de organización con la key → 200/201 con el UUID. Repetir con una key incorrecta → 403.- En el admin de EFC, confirmar
is_verified=Truey la licencia asignada. - Crear una solicitud desde la pantalla del CRM → confirmar el folio
EXP2026-08-0NN. Crear tres más → consecutivos sin huecos. - En el módulo Expedientes del frontend de EFC → los provisionales no aparecen.
curlcon?incluir_provisionales=true→ aparecen, con aduana y patente vacías. - Intentar borrar un provisional desde el admin de EFC → debe negarse (PROTECT).
- En la ficha del embarque, adjuntar un PDF → barra de progreso, y badge
En expedienteal confirmar. - 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/. - En el admin de EFC, confirmar
fuente = APP-CRM,vu = False,document_type.id >= 27. - Abrir el documento desde el CRM → el PDF se descarga y se ve.
- Apagar EFC. Adjuntar otro → la subida tiene éxito con badge
Pendiente de enviar. EnGET /expediente-gateway/outbox, verattemptssubir ylast_errorpoblado cada dos minutos. - Levantar EFC. Esperar un barrido → badge
En expediente, y en EFC hay un solo documento. - Apagar EFC ~17 minutos para forzar 8 fallos → la fila queda
failedy deja de reintentarse.POST /outbox/{id}/retry→ vuelve apendingy se entrega. GET /expediente-gateway/metrics→ los conteos cuadran con el listado.- Completar el expediente con pedimento dummy
0000-0000000→ 200; elpedimento_appcambió, los documentos siguen colgados y descargables, y el objeto en MinIO no se movió. - Recargar el módulo Expedientes de EFC → ahora sí aparece, con sus datos.
- 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. - Borrar un documento del expediente en el CRM → desaparece del CRM y sigue existiendo en EFC.
- 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=fusionarcon opt-in explícito, es un ticket aparte y debe rechazar si el provisional tiene documentos de otrafuente. - Que los documentos del CRM cuenten en
cumplimiento_porcentaje. Llegan contipo_documento_expediente = NULLporquemapa_tipos_por_legacysolo cubre los 7 ids legacy de sistema — exactamente lo que le pasa a Anexo22. PENDIENTE DECISIÓN: si se quiere que cuenten, hay que sembrarTipoDocumentoExpedientepor 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 ysend_invoiceno se tocan. Cuando entre, será concrm_document_ref = INVOICE-{cid}-{id}(estable → idempotente sin key del cliente). - El backfill de documentos legacy del MinIO del CRM. Las filas con
file_keyse quedan conefc_document_id = NULLy la descarga se ramifica en tres (efc_document_id→ proxy;file_key→ presigned;file_url→ externa). Cuando se haga, va por el mismo outbox concrm_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):
NNNda 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 = 8y 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(). SoloPedimento.objects.filter(...).update(...). DocumentType.objects.get_or_create()está prohibido. Solo_resolver_document_type.- No poner
consultar_vucem=Trueen ninguna ruta de este ticket. - No agregar columnas a
Pedimento. Todo el acoplamiento enpedimento_expediente. - No modificar
_doc_to_dict. Extenderlo con_crm_doc_to_dict. - No pasar
pedimento.pedimento_appasave_document. Siempreenlace.storage_token. - No borrar el objeto de MinIO antes de subir el nuevo en el reemplazo.
- No usar
NamedTemporaryFile+atexiten la descarga. - No reusar
MVE_INTEGRATION_API_KEYniANEXO22_INTEGRATION_API_KEY. - No aplicar la exclusión de provisionales a
DescargaExpedienteViewSetni 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 conON CONFLICT, nada más. - No hacer
commitdentro denext_folio. - No hacer la llamada HTTP a EFC dentro del request del usuario. Encolar y despachar.
- No convertir
EfcClienta async.httpx.Clientsíncrono, como el original. - No agregar
autoretry_for,retry_backoffnimax_retriesa 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_keynifile_urlen la respuesta del endpoint nuevo. - No crear el
AsyncClientdel 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_keyniexpediente_archivo_artifact_key: son código muerto de la plantilla y se refieren al expediente del importador de EFC, que es otro concepto (cuelga deImportadorpor 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
migratenialembic 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
.envni datos de clientes reales. - Instrucciones de registrar la actividad en el Kanban. El agente lo hace solo al cerrar el ticket, en el ticket P espejo.
Ambigüedad durante la corrida
Si algo no está en este ticket ni en el código, no adivinar: documentar como
PENDIENTE DECISIÓN: <qué falta y qué se asumió> en el cuerpo del PR y seguir con la asunción más
conservadora.
Riesgos conocidos que no cierra este ticket
resolver_fkdespués del completado.Document.save()re-resuelvepartida/cove/edocumentpor heurística de nombre. En un provisional no hay partidas, pero el pedimento real sí las tiene, así que cualquiersave()sobre un documento del CRM podría auto-ligarlo a una partida ajena. Mitigación: nuncasave()un documento del CRM después de crearlo, y un test que afirme que esas tres FK siguen enNULL._construir_pedimento_apptiene 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 conPermissionChecker, y es un ticket aparte. .env.examplede 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.envde Anexo22.