{ "_meta": { "nombre": "Contrato del carril CRM Agentes de Carga <-> EFC", "ticket": "T2026-08-046", "version": 1, "por_que_existe": "CRM y EFC son dos repos con despliegue independiente. Nada obliga a que sus dos mitades del carril evolucionen juntas, y una ruta renombrada o una clave de payload que cambia solo se descubre en produccion, cuando un documento deja de llegar al expediente. Este archivo es la unica forma de que un cambio unilateral salga rojo en CI.", "como_se_usa": "Cada repo afirma su lado contra ESTE archivo. El CRM en backend/tests/test_contrato_efc.py. EFC debe afirmar el suyo cuando aterricen sus fases 1-4, contra una copia identica byte a byte de este JSON; si las dos copias divergen, el contrato deja de servir para lo unico que sirve.", "regla": "Cambiar algo aqui es cambiar el contrato: obliga a un PR en los DOS repos." }, "carril_efc": { "_nota": "Endpoints maquina-a-maquina que EXPONE EFC y CONSUME el CRM. Header obligatorio en todas: X-Api-Key.", "header_autenticacion": "X-Api-Key", "endpoints": { "organizaciones_buscar": { "metodo": "GET", "path": "/api/v1/organization/integrations/crm/organizaciones/" }, "organizaciones_resolver": { "metodo": "POST", "path": "/api/v1/organization/integrations/crm/organizaciones/resolver/", "request_claves": ["tenant_slug", "tenant_name"], "response_claves": ["id", "nombre", "rfc", "hub_tenant_slug", "is_active", "created"] }, "expediente_crear": { "metodo": "POST", "path": "/api/v1/customs/integrations/crm/expedientes/", "request_claves": [ "crm_tenant_slug", "crm_company_id", "crm_expediente_id", "folio", "storage_token" ], "status_exito": [200, 201], "_nota_status": "201 si el provisional es nuevo, 200 si ya existia. Las dos son exito: el carril es idempotente." }, "expediente_completar": { "metodo": "POST", "path": "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/" }, "expediente_detalle": { "metodo": "GET", "path": "/api/v1/customs/integrations/crm/expedientes/{folio}/" }, "documento_subir": { "metodo": "POST", "path": "/api/v1/record/integrations/crm/documentos/", "content_type": "multipart/form-data", "_nota_content_type": "Multipart, nunca base64: EFC declara parser_classes = [MultiPartParser].", "form_claves": [ "organizacion_id", "crm_company_id", "crm_expediente_id", "tipo", "crm_document_ref" ], "archivo_campo": "file", "status_exito": [200, 201] }, "documentos_listar": { "metodo": "GET", "path": "/api/v1/record/integrations/crm/documentos/list/" }, "documento_descargar": { "metodo": "GET", "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/" }, "documento_eliminar": { "metodo": "DELETE", "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/" }, "documento_reemplazar": { "metodo": "PUT", "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/" } }, "formato_error": { "forma": {"error": {"code": "", "message": ""}}, "_nota": "El worker del CRM ramifica por error.code, NUNCA por el texto del mensaje: un mensaje cambia con cualquier refactor del otro repo y con el se caeria la politica de reintentos sin que nada se vea roto." }, "codigos_error": { "400": [ "payload_invalido", "pedimento_app_reservado", "storage_token_invalido", "efc_pedimento_app_incompleto", "tipo_invalido", "archivo_faltante", "extension_no_permitida", "archivo_demasiado_grande", "espacio_insuficiente" ], "403": ["documento_no_eliminable"], "404": ["expediente_no_encontrado", "documento_no_encontrado"], "409": [ "organizacion_no_utilizable", "licencia_sin_espacio", "expediente_ya_completado", "pedimento_real_ya_existe", "conflicto" ], "502": ["error_storage"] }, "codigos_con_significado_para_el_crm": { "expediente_no_encontrado": { "http": 404, "efecto": "dispara el ensure-then-upload: el CRM crea el provisional y reintenta la subida UNA vez", "_nota": "Si EFC renombra este code, el CRM deja de recuperarse solo y los documentos se quedan pendientes para siempre sin que nada falle a gritos. Es el code mas fragil del carril." } }, "reintentos": { "reintentables": ["5xx", "timeout", "error_de_red"], "no_reintentables": ["4xx"], "_nota": "Un 4xx reintentado tres veces es tres veces el mismo error mas latencia. El corte esta en 500, no en 400." } }, "api_crm": { "_nota": "Endpoints de usuario que expone el CRM. Todos con company_id obligatorio en query y usuario autenticado.", "query_obligatorio": "company_id", "endpoints": [ {"metodo": "GET", "path": "/expedientes"}, {"metodo": "GET", "path": "/expedientes/{expediente_id}"}, {"metodo": "POST", "path": "/expedientes/ensure"}, {"metodo": "POST", "path": "/expedientes/{expediente_id}/completar"}, {"metodo": "DELETE", "path": "/expedientes/{expediente_id}"}, {"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos"}, {"metodo": "POST", "path": "/expedientes/{expediente_id}/documentos"}, {"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos/{document_id}/archivo"}, {"metodo": "DELETE", "path": "/expedientes/{expediente_id}/documentos/{document_id}"}, {"metodo": "GET", "path": "/expediente-gateway/outbox"}, {"metodo": "POST", "path": "/expediente-gateway/outbox/{outbox_id}/retry"}, {"metodo": "GET", "path": "/expediente-gateway/metrics"} ], "documento_response_prohibido": ["file_key", "file_url"], "_nota_prohibido": "La copia local es de transito y se borra al confirmar la entrega a EFC. Exponerla invitaria al frontend a guardarse una referencia que va a dejar de existir; para abrir el archivo esta el proxy de descarga.", "documento_response_claves_minimas": [ "expediente_id", "doc_type", "name", "content_type", "size_bytes", "efc_sync_state", "efc_document_ref" ], "estados_sincronizacion": ["PENDING", "SYNCED", "FAILED"], "descarga_documento": { "tipo_respuesta": "streaming", "traduccion_errores": { "404_de_efc": 404, "cualquier_otro_fallo_de_efc": 502, "documento_de_otro_tenant": 404, "documento_aun_no_entregado": 409 }, "_nota": "La asimetria es deliberada: un 404 de EFC significa que el documento realmente no esta; cualquier otro fallo es de la integracion, no del usuario. Y la traduccion tiene que ocurrir ANTES de que la respuesta empiece a salir, o llega tarde." }, "reintento_outbox": { "exito": {"status": "requeued", "id": ""}, "fila_inexistente_o_de_otro_tenant": 404, "_nota": "404 y no un 200 silencioso: es contrato con el frontend, que distingue 'no se pudo reencolar' de 'reencolado'." }, "metricas_outbox_claves": ["pending", "sent", "failed"], "folio": { "formato": "EXP{YYYY}-{MM}-{NNN}", "alcance_consecutivo": ["tenant", "company", "mes"], "reinicia": "cada mes", "_nota": "Al pasar de 999 crece a 4 digitos en vez de desbordar." } } }