40 Commits

Author SHA1 Message Date
Ernesto Herrera
afe659e56a feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 11s
Build Producción & Push a Harbor / build (push) Has been skipped
- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:58:51 -06:00
Ernesto Herrera
b47dc542f2 feat(crm): Cotizaciones/Cotizador vinculados (Fase B)
- Lista de cotizaciones muestra la Solicitud referenciada (QuoteResponse enriquecido
  con service_request_reference; columna con enlace a la solicitud).
- Cotizador vinculable a una solicitud (?service_request_id=) → prellena modo
  (mapper transport+load→RateMode), ruta, peso/dimensiones, etc.
- Cotizador vinculable a una cotización (?quote_id=) → botón "Agregar" por opción
  que crea el concepto de flete + cargos en la cotización y regresa a ella.
- Botón "Cotizador" en el detalle de la cotización (entrada vinculada).

Suite de cotizaciones en verde. svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:43:53 -06:00
Ernesto Herrera
8431132b10 feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)
- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido"
  (catálogo medio_contacto). Backend leads.preferred_contact_method + migración
  f0a1b2c3d4e5 reversible.
- Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo
  por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al
  host interno minio:9000. RelatedManager.openDoc usa blob→objectURL.

Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:37:30 -06:00
Ernesto Herrera
8c7aeef1a6 feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago)
- Fecha de solicitud automática (hoy) editable al crear.
- Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas,
  aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad.
- Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con
  respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos.
- Agente en destino filtrado a proveedores clasificados corresponsal/aduanal.
- Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida).
- P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida.
- Moneda visible junto al valor de la mercancía.
- Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre.
- Formulario de tarifa: campo "Válida hasta" (calendario).
- Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible).
- Nuevos load types FTL/LTL. Suite backend en verde (109).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:25:20 -06:00
Ernesto Herrera
9c46f5bf3c feat(crm): ajustes de la sesión doc 2 (catálogos, bugs, oportunidades, facturación, UI)
Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
  de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
  activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.

Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
  amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).

Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
  Ganada/Perdida (migración).

Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.

UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).

Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:07:58 -06:00
Ernesto Herrera
f1e6fba75d feat(crm): cotización aérea con peso/volumen (P/Vol) operacional
- Modalidad AÉREO (4ª opción en "¿Cómo desea cotizar?"): en la solicitud
  muestra la sección aérea con el cálculo en vivo P/Vol = (L×A×H cm × bultos)/6000
  y el peso a cobrar = max(peso bruto, P/Vol). Fija el transporte en aéreo.
- Utilidad compartida crm/common/pricing.py (air_volumetric_kg / air_chargeable_kg,
  factor internacional 6000).
- Motor de costeo (rates): la rama aérea usa el P/Vol por dimensiones si vienen
  (CostRequest ahora acepta length/width/height_cm); respaldo m³×167 cuando no.
- Cotizador: captura por dimensiones (L×A×H + bultos) en modo aéreo y muestra el P/Vol.
- Solicitud→Cotización: si es AÉREO, siembra el concepto de flete con cantidad =
  peso a cobrar (P/Vol) para capturar la tarifa por kg.
- Pruebas: test_pricing (ejemplo del doc → 720; max bruto/volumétrico) + cotización
  aérea desde solicitud. Suite en verde (108).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:30:25 -06:00
Ernesto Herrera
36e98ee976 feat(crm): costo estimado por servicio adicional, folios visibles y sidebar por flujo
- Solicitud: al marcar un servicio adicional se habilita su costo estimado
  (columna JSON additional_service_costs). Al cotizar, cada servicio marcado se
  siembra como concepto de la cotización con ese costo de partida (costo=venta).
- Folios visibles: se muestran en la tarjeta de Oportunidad del kanban y se aclara
  en el formulario que el folio se asigna al guardar (las listas ya lo mostraban).
- Sidebar CRM reordenado por flujo comercial (captación → embudo → solicitud →
  cotización → catálogos de apoyo).
- Migración c2d3e4f5a6b7 aditiva y reversible. Suite backend en verde (102).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:00:42 -06:00
Ernesto Herrera
915bdd19fe feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial
Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
  mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).

Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.

Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).

Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).

Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:51:40 -06:00
Ernesto Herrera
87d23b3d23 feat(crm): rediseño profesional del PDF de cotización
Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de
sección en color, tabla de costos con encabezado y filas alternadas, caja de
totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1);
oculta secciones sin datos; color por defecto navy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:16 -06:00
Ernesto Herrera
e269e46d88 fix(crm): envío de cotización por correo con aiosmtplib.send (sin doble STARTTLS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:23 -06:00
Ernesto Herrera
03055cd377 fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)
La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el
navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend
(vía nginx) y el visor usa un blob autenticado (api.getBlob).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:33:14 -06:00
Ernesto Herrera
e09cb02b8b feat(crm): cotización — Ver PDF, Enviar por correo y pantalla de marca por tenant
- Cliente API: quotesAPI.pdfUrl/sendEmail + quoteSettingsAPI (get/save/uploadLogo).
- Cotización: botones Ver PDF y Enviar por correo (modal con destinatario/asunto/mensaje).
- Configuración → Formato de cotización: emisor, logo, color, prefijo y textos por
  defecto (por compañía).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:22:24 -06:00
Ernesto Herrera
206ff450f8 feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo
- Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía
  + columna quotes.pdf_file_key + migración con down().
- Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos,
  resumen, condiciones); logo incrustado como JPEG (Pillow).
- Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF),
  GET/PUT /quote-settings, POST /quote-settings/logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:18:07 -06:00
Ernesto Herrera
ef7e69ed57 feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas
- Backend: CRUD de cargos (rate_charges) por tarifario.
- Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres
  para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:46:25 -06:00
Ernesto Herrera
fa542ddf18 feat(crm): frontend del módulo Tarifario — tarifarios, import Excel y Cotizador
- Cliente API rateSheetsAPI (CRUD, plantilla, import con vista previa, /rate-quote).
- Tarifarios: listado + importar por Excel (plantilla, vista previa, validación) +
  detalle con rutas y activar/vencer.
- Cotizador: calcula opciones de costo por proveedor desde los tarifarios vigentes.
- Nav CRM: Tarifarios y Cotizador.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:35:48 -06:00
Ernesto Herrera
2ae6901b6a feat(crm): módulo Tarifario — modelos, CRUD, import Excel y motor de costeo
- Tablas rate_sheets/lanes/breaks/charges (esquema crm) + migración con down().
- Catálogos nuevos: modo_tarifario, unidad_tarifa, concepto_cargo.
- CRUD de tarifarios y rutas; descarga de plantilla Excel por modo; import con
  vista previa y validación; alta directa desde Excel.
- Motor de costeo /rate-quote: aéreo (peso facturable + quiebres + optimización),
  marítimo FCL (por contenedor), LCL (W/M) y terrestre; suma cargos adicionales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:28:49 -06:00
Ernesto Herrera
8576ec7e37 feat(crm): catálogo Tipos de equipo/contenedor con medidas (Medidas de Equipos)
Agrega el catálogo global 'tipo_equipo' (27 opciones: contenedores marítimos,
ULD aéreos y remolques terrestres) con dimensiones en el campo extra (modo,
largo/ancho/alto, capacidad m³, tara, carga máx., pallets). Base para la sección
FCL del módulo de Cotización (T2026-07-183). Expone extra en el API de catálogos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:10:19 -06:00
Ernesto Herrera
de8557dec2 feat(crm): formularios Clientes/Prospectos y Proveedores por catálogo — T2026-07-081/082
Frontend de los catálogos de referencia en BD:
- Store crm-catalogs + cliente API; selects poblados desde /v1/crm/catalogs
  (fiscal: régimen, uso CFDI, forma/método de pago SAT, moneda ISO; comercial;
  país/estado/tipo de domicilio/área). Muestran la descripción, no la clave.
- "Otro → especificar" (medio de contacto y clasificación), observaciones
  generales, y datos de auditoría (ID, fechas, usuarios) en la vista de edición.
- Botón "Editar" explícito en los listados.
- Pantalla de administración de catálogos (insertar/editar/borrar): globales
  solo activar/desactivar; los de la empresa con alta/edición/borrado.
- addresses.country ampliado a 3 (país ISO alfa-3) + migración con down().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
a2a474f8c8 chore(deploy): heap de Node en build + runtime sin red + nginx buffers/http2
Cambios de infra ya aplicados en testing: NODE_OPTIONS max-old-space-size para
evitar OOM en vite build; runtime del frontend sin apk/npm (busybox/node directo);
nginx con proxy_buffers grandes (evita 502 por header del JWT) y http2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
8aef99df9c feat(crm): catálogos de referencia en BD (SAT/ISO + cliente) — T2026-07-081/082
- Modelo crm.catalog_items (global tenant_id NULL / por tenant) + migración con
  índices únicos parciales y down().
- Seed de 18 catálogos globales (485 opciones): tipo registro/persona, estatus,
  giro, clasificaciones, medio contacto, idioma, régimen, uso CFDI, forma/método
  de pago SAT, moneda/país ISO, estados MX, tipo domicilio, área, cobertura.
- Servicio + endpoints CRUD /v1/crm/catalogs (listar/insertar/editar/borrar),
  con global solo para hub_admin y catálogos del cliente por tenant.
- Columnas nuevas: accounts.commercial_observations, accounts.preferred_contact_other,
  suppliers.classification_other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:15:52 -06:00
Ernesto Herrera
5f9c7cf000 docs(deploy): runbook de despliegue a producción (auth SIWEB + RBAC)
Pasos para que un operador con acceso a prod despliegue con seguridad: env,
build del frontend en CI, up del stack, seed + sync de permisos, smoke test y
rollback. Incluye feature-flag SESSION_STORE_ENABLED como mitigación rápida.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:34:30 -06:00
Ernesto Herrera
71be225b33 fix(crm): guardar permisos de rol y recargar roles/usuarios tras refresh
- role-permissions.assign enviaba permission_id en el body pero el backend lo
  espera como query param → 422 silencioso; el permiso no se guardaba. Ahora va
  en la query y se confirma con un toast al marcar cada permiso (auto-guardado).
- Roles y Usuarios recargaban solo con el evento companyChanged, que no dispara
  en la hidratación inicial; por eso tras refrescar no aparecían hasta re-elegir
  la compañía. Se cambia a un $effect que reacciona a la compañía activa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 06:43:35 -06:00
Ernesto Herrera
0cffd351df fix(crm): alta de usuarios carga compañías del tenant en vez de slug a mano
La organización destino salía como texto libre (o fallaba al listar todos los
tenants del Hub con un token KC caduco). Ahora se carga desde /v1/auth/my-companies
(mismo origen que el switcher) y se elige la compañía; el tenant_slug se resuelve
de la compañía seleccionada.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:03:36 -06:00
Ernesto Herrera
a14acf59e5 chore(crm): ocultar pestaña Workspace del sidebar
Las altas de organizaciones/usuarios se gestionan desde Compañías, Usuarios y
Roles y permisos; se retira la entrada Workspace (y su icono Building2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:56:59 -06:00
Ernesto Herrera
0d8c4ddf62 fix(crm): catálogo de permisos no cargaba por redirect 307 (slash final)
permissionsAPI.list pegaba a /v1/core/permissions/ (con slash); la ruta backend
es @router.get("") sin slash, así que FastAPI devolvía 307 y el cliente no lo
seguía → "No se pudieron cargar roles/permisos". Se quitan los slash finales de
list/getById/create/update/delete/getModules/getActions para que matcheen exacto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:51:18 -06:00
Ernesto Herrera
de9e35c501 feat(core): botón "dar de alta usuario" (invitación) en Usuarios + fix hub_admin
- Frontend Usuarios: formulario para dar de alta (email + rol) → invitación; muestra
  el enlace copiable por si el correo no llega.
- Backend invites: resuelve tenant_slug desde la compañía cuando el token no lo trae
  (hub_admin) y usa el token KC de la sesión (valkey) para crear el invite en el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:12:11 -06:00
Ernesto Herrera
868724d1f2 fix(core): usar token KC (valkey) para listar usuarios del tenant, no la sesión local
list_users/stats llamaban al Hub (users-with-info) con el Bearer de la app, que con
el patrón SIWEB es la sesión local (HS256) → el Hub la rechaza (401) → 0 usuarios.
Nuevo core.hub_token.get_hub_access_token: toma el token KC de la sesión (valkey vía
crm_sid) y lo refresca si está por expirar. list_users y stats lo usan para el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:03:37 -06:00
Ernesto Herrera
cd3f8c62a4 feat(core): gestión de Usuarios y Roles/permisos (pantallas)
- Roles y permisos: crear/eliminar roles por compañía y editar sus permisos
  (checkboxes agrupados por módulo) vía rolesAPI + rolePermissionsAPI + permissionsAPI.
- Usuarios: lista usuarios de la compañía activa y permite asignar/quitar roles
  vía usersAPI + userRolesAPI. Reusa los clientes API ya existentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:31:11 -06:00
Ernesto Herrera
387b3c0e78 feat(core,crm): auto-sync de tenants Workspace→CRM + mostrar tenant de la compañía activa
- assignable-tenants: para hub_admin, sincroniza automáticamente los tenants del
  Workspace (Hub GET /hub/tenants) a core.tenants con su mismo ID, y los devuelve.
  Así los tenants creados en el Workspace aparecen solos en el CRM para asignarles
  compañías (best-effort con el token KC de la sesión). CRM→Workspace ya lo hace
  Organizaciones (POST /hub/tenants).
- my-companies devuelve tenant_name/tenant_slug (join core.tenants).
- Switcher muestra el tenant de la compañía activa (antes "Sin tenant asignado").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:20:50 -06:00
Ernesto Herrera
706da34f4a feat(core): auto-ligar usuario a todas las compañías de su tenant (sin rol)
Antes solo se ligaba al usuario que CREABA la compañía; un segundo usuario del mismo
tenant no quedaba ligado → veía "sin compañía". Ahora, al entrar, el usuario con
tenant queda como MIEMBRO de todas las compañías de su tenant (solo membresía; el rol
lo asigna un admin, salvo el primer usuario que recibe super_admin en /permissions/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:44:51 -06:00
Ernesto Herrera
ce8b042e84 fix(auth): hornear is_hub_admin (autoritativo del Hub) en la sesión local
create_company (y otros checks) usan is_hub_admin, pero la sesión local se emitía
desde el token KC crudo, que no trae ese claim → el hub_admin sin tenant recibía 403
al crear compañía. Ahora la sesión se emite con los claims de /auth/me del Hub
(is_hub_admin, roles), con fallback al decode del token KC si el Hub no responde.
Se mantiene intacto el control de autorización (solo hub_admin crea fuera de su tenant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:27:27 -06:00
Ernesto Herrera
63ad2e2ecd feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:44:36 -06:00
Ernesto Herrera
0b6848bbdd fix(core): crear compañía real en a76.company por tenant (arregla FK y desbloqueo)
my-companies usaba company_id = tenant_id, pero user_tenants.company_id tiene FK a
a76.company (entidad real heredada de Anexo76). Con el tenant real (aduanasoft=11)
no existía fila de compañía → ForeignKeyViolation → sin compañía → todo bloqueado.

Ahora my-companies lista las compañías del tenant desde a76.company y, si no hay
ninguna, crea una por defecto (nombre = el del tenant) en el primer acceso, usando
su id real; luego asegura el vínculo usuario↔compañía. Base para el módulo de
gestión de compañías.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:31:59 -06:00
Ernesto Herrera
29170f7c8c fix(auth): validar licencia sin reenviar la sesión local al Hub (rompe el bucle 401)
El LicenseValidationMiddleware reenviaba el Bearer al Hub /verify-license, pero con
el patrón SIWEB el Bearer es un JWT HS256 local que el Hub no entiende → 401 →
silent-refresh infinito → toast "Sesión expirada" en cada página.

Ahora, para una sesión local válida, la licencia se valida con el token KC guardado
en valkey (refrescándolo si está vencido) y se cachea por tenant (TTL 10 min). Si el
Hub no es concluyente (su refresh falla), se permite el paso (la sesión se emitió tras
un login válido) evitando el bucle; los resultados concluyentes sí se cachean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:43:32 -06:00
Ernesto Herrera
b76d42be83 refactor(auth): login 100% vía Workspace/Hub — sin comunicación directa a Keycloak
Patrón SIWEB: el CRM ya NO habla directo a Keycloak. Se elimina el flujo OIDC
paralelo (que causaba el bucle e "issuer mismatch"):

- workspace-auth.ts: se quitan builders de URL de KC (authorization/login/logout)
  y getPublicKeycloakBaseUrl/Realm/ClientId. getWorkspaceLoginUrl ya no lleva
  return_to a /login?sso_verified (evita el rebote sin sesión = bucle).
- /login: sso_verified=1 → /dashboard; sin sesión → App Launcher del Workspace
  (relay). Se eliminan redirectToKeycloakAuthorization/Login.
- /auth/callback: obsoleto — ya no intercambia code con KC; redirige a /dashboard.
- /logout y /auth/post-logout: limpian sesión local y vuelven al Workspace (el
  logout completo del Hub/KC se hace desde el Workspace).
- /join: usa redirectToWorkspaceLogin en vez de KC.
- lib/auth.ts: initAuth ya no inicializa keycloak-js en el browser; getToken y
  refreshAccessToken operan por cookie + silent-refresh (backend → Hub).

Login = solo App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:01:10 -06:00
Ernesto Herrera
223395b430 feat(core): compañía por tenant (1:1) en my-companies para desbloquear el dashboard
get_my_companies devolvía [] (STUB) → sin compañía activa el CRM bloqueaba todo.
Modelo: una empresa por tenant (company_id = tenant_id, como el agente de carga).
Asegura el vínculo user↔tenant↔company; los permisos se resuelven en
/permissions/me (bootstrap de super_admin al primer usuario).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:05:44 -06:00
Ernesto Herrera
579c6e1f19 chore(deploy): activar sesión local (patrón SIWEB) en testing
Pasa SESSION_STORE_ENABLED/IDLE/MAX al backend vía environment del override
(el contenedor no lee .env). SESSION_STORE_ENABLED=false revierte el patrón.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:03:01 -06:00
Ernesto Herrera
5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/
refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad
(SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey.
La app usa esa sesión local como bearer (cookie access_token); el token KC vive en
kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub
solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle
causado por el bug "Token is not active" del relay.

Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación.

Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico).

Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta
la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id;
test unitario de local_session.
Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/
silent-refresh/switch-tenant y pantallas de workspace cableadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:50:35 -06:00
Ernesto Herrera
a613a7a6aa feat(crm,workspace): alta de organizaciones y usuarios vía Hub
Nueva sección "Workspace" (Organizaciones + Usuarios) que orquesta el Hub
reenviando el token del usuario autenticado. Sin secretos en el CRM ni
bypass: la autorización la impone el Hub (hub_admin para tenants;
hub_admin o admin del tenant para invitaciones).

- Organizaciones: listar (GET /api/v1/hub/tenants) y crear
  (POST /api/v1/hub/tenants → tenant + realm Keycloak).
- Usuarios: invitación de un solo uso (POST /api/v1/hub/invites) con
  enlace copiable si el correo no llega.
- Se descarta /auth/provision-user (PROVISION_SECRET, machine-to-machine)
  en favor del flujo de invitación con token de admin.
- Helpers puros (slug, validación, extracción de errores del Hub) con
  tests unitarios; estado "requiere permisos" ante 403 y fallback a slug
  manual para admin de tenant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:19:21 -06:00
Aduanasoft
45f128a551 fix(deploy): apk add wget best-effort en Dockerfile.prod (redes sin CDN de Alpine)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:54:57 -06:00
188 changed files with 10455 additions and 7001 deletions

View File

@@ -112,23 +112,3 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
SPOKE_URLS=""
# ── EFC (expediente electronico) ─────────────────────────────────────────────
# Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC.
# Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC.
#
# EFC_API_URL VACIA = integracion APAGADA. Todo el enganche es best-effort y hace no-op:
# el CRM sigue funcionando igual, guardando los archivos solo en su MinIO.
# EFC_API_KEY debe coincidir con CRM_INTEGRATION_API_KEY del lado de EFC.
EFC_API_URL=
EFC_API_KEY=
EFC_API_VERIFY_SSL=true
# Metadatos: resolver organizacion, crear expediente, completar.
EFC_API_TIMEOUT_MS=8000
# Subidas. Debe quedar POR DEBAJO del proxy_read_timeout del nginx de EFC: si el CRM
# esperara mas, veria un 504 opaco y no sabria si el documento entro.
EFC_UPLOAD_TIMEOUT_MS=55000
# Scaffolding de mTLS (pre-produccion). Vacio = TLS normal.
EFC_MTLS_CA_PATH=
EFC_MTLS_CERT_PATH=
EFC_MTLS_KEY_PATH=

View File

@@ -0,0 +1,60 @@
"""crm quote_settings (marca por tenant) + quotes.pdf_file_key
Revision ID: a0b1c2d3e4f5
Revises: f8a9b0c1d2e3
Create Date: 2026-07-29 00:00:00.000000
PDF de cotización con formato maestro + branding por tenant + envío por correo.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "a0b1c2d3e4f5"
down_revision: Union[str, None] = "f8a9b0c1d2e3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("quotes", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema=SCHEMA)
op.create_table(
"quote_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("emitter_name", sa.String(length=255), nullable=True),
sa.Column("emitter_rfc", sa.String(length=13), nullable=True),
sa.Column("emitter_address", sa.Text(), nullable=True),
sa.Column("emitter_phone", sa.String(length=60), nullable=True),
sa.Column("emitter_email", sa.String(length=255), nullable=True),
sa.Column("emitter_website", sa.String(length=255), nullable=True),
sa.Column("logo_file_key", sa.String(length=512), nullable=True),
sa.Column("accent_color", sa.String(length=9), nullable=True, server_default=sa.text("'#2f6bf0'")),
sa.Column("quote_prefix", sa.String(length=12), nullable=True, server_default=sa.text("'COT'")),
sa.Column("default_terms", sa.Text(), nullable=True),
sa.Column("footer_note", sa.Text(), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
schema=SCHEMA,
)
op.create_index("ix_crm_quote_settings_id", "quote_settings", ["id"], schema=SCHEMA)
op.create_index("ix_crm_quote_settings_tenant_id", "quote_settings", ["tenant_id"], schema=SCHEMA)
op.create_index("ix_crm_quote_settings_company_id", "quote_settings", ["company_id"], schema=SCHEMA)
# Una configuración por compañía
op.create_index(
"uq_crm_quote_settings_company", "quote_settings", ["tenant_id", "company_id"],
unique=True, schema=SCHEMA, postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_table("quote_settings", schema=SCHEMA)
op.drop_column("quotes", "pdf_file_key", schema=SCHEMA)

View File

@@ -0,0 +1,158 @@
"""Campos del documento maestro de cotización en la solicitud + folios del ciclo comercial
Revision ID: b1c2d3e4f5a6
Revises: a0b1c2d3e4f5
Create Date: 2026-08-03 00:00:00.000000
Amplía crm.service_requests con los campos que exige el documento maestro de
cotización, agrega los back-links y la dirección impo/expo del ciclo
Oportunidad→Solicitud→Cotización→Operación, y crea crm.folio_counters para los
folios auto-generados ({LETRA}{AAAA}-{MM}-{NNN}-{DIR}).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b1c2d3e4f5a6"
down_revision: Union[str, None] = "a0b1c2d3e4f5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
# Columnas nuevas de crm.service_requests (nombre, tipo, kwargs).
_SR_COLUMNS = [
("contact_id", sa.Integer(), {}),
("request_date", sa.Date(), {}),
("currency", sa.String(length=3), {}),
("priority", sa.String(length=20), {}),
("origin_country", sa.String(length=3), {}),
("origin_city", sa.String(length=120), {}),
("origin_port", sa.String(length=20), {}),
("destination_country", sa.String(length=3), {}),
("destination_city", sa.String(length=120), {}),
("destination_port", sa.String(length=20), {}),
("pickup_location", sa.String(length=255), {}),
("delivery_location", sa.String(length=255), {}),
("estimated_shipment_date", sa.Date(), {}),
("cargo_value", sa.Numeric(14, 2), {}),
("insurance_required", sa.Boolean(), {"server_default": sa.text("false")}),
("hs_code", sa.String(length=20), {}),
("goods_origin_country", sa.String(length=3), {}),
("hazardous_imo", sa.Boolean(), {"server_default": sa.text("false")}),
("refrigerated", sa.Boolean(), {"server_default": sa.text("false")}),
("stackable", sa.Boolean(), {"server_default": sa.text("false")}),
("pieces_count", sa.Integer(), {}),
("boxes_count", sa.Integer(), {}),
("pallets_count", sa.Integer(), {}),
("net_weight", sa.Numeric(14, 3), {}),
("length_cm", sa.Numeric(10, 2), {}),
("width_cm", sa.Numeric(10, 2), {}),
("height_cm", sa.Numeric(10, 2), {}),
("measurement_unit", sa.String(length=20), {}),
("container_count", sa.Integer(), {}),
("packaging_type", sa.String(length=20), {}),
("oversized", sa.Boolean(), {"server_default": sa.text("false")}),
("weight_per_pallet", sa.Numeric(14, 3), {}),
("volume_per_pallet", sa.Numeric(14, 3), {}),
("additional_services", sa.JSON(), {}),
("payment_method", sa.String(length=20), {}),
("client_notes", sa.Text(), {}),
("internal_notes", sa.Text(), {}),
]
def upgrade() -> None:
# ----- crm.service_requests: campos del documento maestro de cotización -----
for name, col_type, kwargs in _SR_COLUMNS:
nullable = "server_default" not in kwargs # los boolean quedan NOT NULL con default false
op.add_column(
"service_requests",
sa.Column(name, col_type, nullable=nullable, **kwargs),
schema=SCHEMA,
)
op.create_foreign_key(
"fk_crm_service_requests_contact_id", "service_requests", "contacts",
["contact_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_service_requests_contact_id", "service_requests", ["contact_id"], schema=SCHEMA
)
# ----- crm.documents: adjuntos de una solicitud -----
op.add_column(
"documents", sa.Column("service_request_id", sa.Integer(), nullable=True), schema=SCHEMA
)
op.create_foreign_key(
"fk_crm_documents_service_request_id", "documents", "service_requests",
["service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_documents_service_request_id", "documents", ["service_request_id"], schema=SCHEMA
)
# ----- crm.opportunities: dirección impo/expo + folio + back-link a la solicitud -----
op.add_column("opportunities", sa.Column("operation_type", sa.String(length=20), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("reference", sa.String(length=40), nullable=True), schema=SCHEMA)
op.add_column(
"opportunities",
sa.Column("converted_service_request_id", sa.Integer(), nullable=True),
schema=SCHEMA,
)
op.create_foreign_key(
"fk_crm_opportunities_converted_sr", "opportunities", "service_requests",
["converted_service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_opportunities_reference", "opportunities", ["reference"], schema=SCHEMA
)
# ----- crm.quotes: variante FCL/LCL para la comparación "Ambas" -----
op.add_column("quotes", sa.Column("load_type", sa.String(length=10), nullable=True), schema=SCHEMA)
# ----- crm.folio_counters: consecutivo mensual por compañía y entidad -----
op.create_table(
"folio_counters",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("entity", sa.String(length=4), nullable=False),
sa.Column("period", sa.String(length=7), nullable=False),
sa.Column("last_number", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.UniqueConstraint(
"tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope"
),
schema=SCHEMA,
)
op.create_index("ix_crm_folio_counters_id", "folio_counters", ["id"], schema=SCHEMA)
op.create_index("ix_crm_folio_counters_tenant_id", "folio_counters", ["tenant_id"], schema=SCHEMA)
op.create_index("ix_crm_folio_counters_company_id", "folio_counters", ["company_id"], schema=SCHEMA)
def downgrade() -> None:
op.drop_index("ix_crm_folio_counters_company_id", table_name="folio_counters", schema=SCHEMA)
op.drop_index("ix_crm_folio_counters_tenant_id", table_name="folio_counters", schema=SCHEMA)
op.drop_index("ix_crm_folio_counters_id", table_name="folio_counters", schema=SCHEMA)
op.drop_table("folio_counters", schema=SCHEMA)
op.drop_column("quotes", "load_type", schema=SCHEMA)
op.drop_index("ix_crm_opportunities_reference", table_name="opportunities", schema=SCHEMA)
op.drop_constraint("fk_crm_opportunities_converted_sr", "opportunities", schema=SCHEMA, type_="foreignkey")
op.drop_column("opportunities", "converted_service_request_id", schema=SCHEMA)
op.drop_column("opportunities", "reference", schema=SCHEMA)
op.drop_column("opportunities", "operation_type", schema=SCHEMA)
op.drop_index("ix_crm_documents_service_request_id", table_name="documents", schema=SCHEMA)
op.drop_constraint("fk_crm_documents_service_request_id", "documents", schema=SCHEMA, type_="foreignkey")
op.drop_column("documents", "service_request_id", schema=SCHEMA)
op.drop_index("ix_crm_service_requests_contact_id", table_name="service_requests", schema=SCHEMA)
op.drop_constraint("fk_crm_service_requests_contact_id", "service_requests", schema=SCHEMA, type_="foreignkey")
for name, _col_type, _kwargs in reversed(_SR_COLUMNS):
op.drop_column("service_requests", name, schema=SCHEMA)

View File

@@ -0,0 +1,33 @@
"""Costo estimado por servicio adicional en la solicitud de servicio
Revision ID: c2d3e4f5a6b7
Revises: b1c2d3e4f5a6
Create Date: 2026-08-04 00:00:00.000000
Agrega crm.service_requests.additional_service_costs (JSON: {codigo_servicio: costo})
para capturar el costo estimado de cada servicio adicional marcado; ese costo se
usa como punto de partida al sembrar los conceptos de la cotización.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c2d3e4f5a6b7"
down_revision: Union[str, None] = "b1c2d3e4f5a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column(
"service_requests",
sa.Column("additional_service_costs", sa.JSON(), nullable=True),
schema=SCHEMA,
)
def downgrade() -> None:
op.drop_column("service_requests", "additional_service_costs", schema=SCHEMA)

View File

@@ -0,0 +1,56 @@
"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos
Revision ID: d3e4f5a6b7c8
Revises: c2d3e4f5a6b7
Create Date: 2026-08-04 01:00:00.000000
- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais).
- crm.accounts.industry_other (especificar cuando el giro es "otro").
- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en
los valores guardados en accounts/suppliers; y país 'MX''MEX'.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, None] = "c2d3e4f5a6b7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
# País a ISO alfa-3 en accounts (addresses ya es String(3)).
# Primero se amplía la columna; luego se normaliza el dato (evita truncamiento).
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=2), type_=sa.String(length=3),
existing_nullable=True, server_default=sa.text("'MEX'"),
)
op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'")
op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'")
# Giro "otro" — campo para especificar
op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados)
op.execute(
"UPDATE crm.catalog_items SET code = lpad(code, 2, '0') "
"WHERE catalog = 'forma_pago' AND char_length(code) = 1"
)
op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
def downgrade() -> None:
op.drop_column("accounts", "industry_other", schema=SCHEMA)
# Regresar país a String(2) sin truncar filas existentes
op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'")
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=3), type_=sa.String(length=2),
existing_nullable=True, server_default=sa.text("'MX'"),
)
# La normalización de formas de pago no se revierte (evita romper códigos multi-dígito).

View File

@@ -0,0 +1,75 @@
"""Expediente (crm.cases) + case_id en el ciclo comercial
Revision ID: d4e5f6a7b8c9
Revises: f0a1b2c3d4e5
Create Date: 2026-08-07 02:00:00.000000
Crea crm.cases (expediente, hilo maestro con folio EXP...) y agrega case_id a
crm.opportunities/service_requests/quotes, ops.shipments y fin.invoices.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "f0a1b2c3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (schema, tabla) donde se agrega case_id
_CASE_FK_TABLES = [
("crm", "opportunities"),
("crm", "service_requests"),
("crm", "quotes"),
("ops", "shipments"),
("fin", "invoices"),
]
def upgrade() -> None:
op.create_table(
"cases",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("stage", sa.String(length=20), nullable=False, server_default=sa.text("'oportunidad'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="crm",
)
op.create_index("ix_crm_cases_id", "cases", ["id"], schema="crm")
op.create_index("ix_crm_cases_reference", "cases", ["reference"], schema="crm")
op.create_index("ix_crm_cases_tenant_id", "cases", ["tenant_id"], schema="crm")
op.create_index("ix_crm_cases_company_id", "cases", ["company_id"], schema="crm")
op.create_index("ix_crm_cases_account_id", "cases", ["account_id"], schema="crm")
op.create_index("ix_crm_cases_status", "cases", ["status"], schema="crm")
for schema, table in _CASE_FK_TABLES:
op.add_column(table, sa.Column("case_id", sa.Integer(), nullable=True), schema=schema)
op.create_index(f"ix_{schema}_{table}_case_id", table, ["case_id"], schema=schema)
op.create_foreign_key(
f"fk_{schema}_{table}_case_id", table, "cases",
["case_id"], ["id"], source_schema=schema, referent_schema="crm",
)
def downgrade() -> None:
for schema, table in _CASE_FK_TABLES:
op.drop_constraint(f"fk_{schema}_{table}_case_id", table, schema=schema, type_="foreignkey")
op.drop_index(f"ix_{schema}_{table}_case_id", table_name=table, schema=schema)
op.drop_column(table, "case_id", schema=schema)
for idx in ("status", "account_id", "company_id", "tenant_id", "reference", "id"):
op.drop_index(f"ix_crm_cases_{idx}", table_name="cases", schema="crm")
op.drop_table("cases", schema="crm")

View File

@@ -0,0 +1,30 @@
"""Fechas separadas de ganada/perdida en la oportunidad
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-08-04 02:00:00.000000
Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas,
editables) además de closed_at y lost_reason ya existentes.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("opportunities", "lost_date", schema=SCHEMA)
op.drop_column("opportunities", "won_date", schema=SCHEMA)

View File

@@ -0,0 +1,90 @@
"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers
Revision ID: e6f7a8b9c0d1
Revises: d5e6f7a8b9c0
Create Date: 2026-07-22 00:00:00.000000
Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores):
catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes
(observaciones comerciales, "otro" de medio de contacto y de clasificación).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e6f7a8b9c0d1"
down_revision: Union[str, None] = "d5e6f7a8b9c0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
# ----- crm.catalog_items -----
op.create_table(
"catalog_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("catalog", sa.String(length=60), nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("parent_catalog", sa.String(length=60), nullable=True),
sa.Column("parent_code", sa.String(length=64), nullable=True),
# NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant.
sa.Column("tenant_id", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("extra", sa.JSON(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("id"),
schema=SCHEMA,
)
op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA)
op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA)
op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA)
op.create_index(
"ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA
)
# Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas.
op.create_index(
"uq_crm_catalog_items_global",
"catalog_items",
["catalog", "code"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("tenant_id IS NULL"),
)
op.create_index(
"uq_crm_catalog_items_tenant",
"catalog_items",
["catalog", "code", "tenant_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("tenant_id IS NOT NULL"),
)
# ----- columnas nuevas -----
# Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto.
op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA)
op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Proveedores: "otro" de la clasificación múltiple.
op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("suppliers", "classification_other", schema=SCHEMA)
op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA)
op.drop_column("accounts", "commercial_observations", schema=SCHEMA)
op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA)
op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA)
op.drop_table("catalog_items", schema=SCHEMA)

View File

@@ -1,163 +0,0 @@
"""Expediente del CRM y su espejo en EFC: crm.expedientes, el contador de folios y las columnas
del espejo en las dos tablas de documentos.
Revision ID: e6f7a8b9c0d1
Revises: d5e6f7a8b9c0
Create Date: 2026-08-07 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e6f7a8b9c0d1"
down_revision: Union[str, None] = "d5e6f7a8b9c0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Las diez columnas del espejo en EFC. Van idénticas en crm.documents y en ops.shipment_documents
# porque las dos alimentan el mismo expediente electrónico: si divergieran, la UI pintaría un badge
# distinto según de dónde viniera el documento. Se define una vez aquí y se aplica en bucle, para
# que no se puedan desalinear al editar la migración.
def _columnas_espejo_efc() -> list[sa.Column]:
return [
sa.Column("expediente_id", sa.Integer(), nullable=True),
sa.Column("efc_document_ref", sa.String(length=64), nullable=True),
sa.Column("efc_document_id", sa.String(length=36), nullable=True),
sa.Column("efc_sync_state", sa.String(length=20), nullable=True, server_default=sa.text("'PENDING'")),
sa.Column("efc_synced_at", sa.DateTime(), nullable=True),
sa.Column("efc_error_code", sa.String(length=60), nullable=True),
sa.Column("efc_error_detail", sa.Text(), nullable=True),
sa.Column("efc_attempts", sa.Integer(), nullable=True, server_default=sa.text("0")),
sa.Column("content_sha256", sa.String(length=64), nullable=True),
]
_TABLAS_CON_ESPEJO = (
("documents", "crm"),
("shipment_documents", "ops"),
)
def upgrade() -> None:
# ---------- crm.expedientes ----------
op.create_table(
"expedientes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("folio", sa.String(length=20), nullable=False),
sa.Column("period_year", sa.Integer(), nullable=False),
sa.Column("period_month", sa.Integer(), nullable=False),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("service_request_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
sa.Column("efc_organizacion_id", sa.String(length=36), nullable=True),
sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True),
sa.Column("efc_storage_token", sa.String(length=25), nullable=True),
sa.Column("efc_link_state", sa.String(length=20), nullable=False, server_default=sa.text("'PENDING'")),
sa.Column("efc_error_code", sa.String(length=60), nullable=True),
sa.Column("efc_error_detail", sa.Text(), nullable=True),
sa.Column("patente", sa.String(length=20), nullable=True),
sa.Column("aduana", sa.String(length=10), nullable=True),
sa.Column("numero_pedimento", sa.String(length=20), nullable=True),
sa.Column("anio", sa.Integer(), nullable=True),
sa.Column("clave_pedimento", sa.String(length=10), nullable=True),
sa.Column("regimen", sa.String(length=10), nullable=True),
sa.Column("fecha_pago", sa.Date(), nullable=True),
sa.Column("rfc_importador", sa.String(length=20), nullable=True),
sa.Column("rfc_agente_aduanal", sa.String(length=100), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
# Las dos redes de seguridad del folio: si el contador se corrompe, un duplicado falla
# ruidosamente en vez de mezclar dos hilos documentales.
sa.UniqueConstraint("tenant_id", "company_id", "folio", name="uq_crm_expedientes_folio"),
sa.UniqueConstraint(
"tenant_id", "company_id", "period_year", "period_month", "sequence",
name="uq_crm_expedientes_periodo_seq",
),
schema="crm",
)
op.create_index("ix_crm_expedientes_id", "expedientes", ["id"], schema="crm")
op.create_index("ix_crm_expedientes_folio", "expedientes", ["folio"], schema="crm")
op.create_index("ix_crm_expedientes_status", "expedientes", ["status"], schema="crm")
op.create_index("ix_crm_expedientes_tenant_id", "expedientes", ["tenant_id"], schema="crm")
op.create_index("ix_crm_expedientes_company_id", "expedientes", ["company_id"], schema="crm")
op.create_index(
"ix_crm_expedientes_service_request_id", "expedientes", ["service_request_id"], schema="crm"
)
op.create_index("ix_crm_expedientes_account_id", "expedientes", ["account_id"], schema="crm")
op.create_foreign_key(
"fk_crm_expedientes_tenant_id", "expedientes", "tenants",
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
)
op.create_foreign_key(
"fk_crm_expedientes_service_request_id", "expedientes", "service_requests",
["service_request_id"], ["id"], source_schema="crm", referent_schema="crm",
)
op.create_foreign_key(
"fk_crm_expedientes_account_id", "expedientes", "accounts",
["account_id"], ["id"], source_schema="crm", referent_schema="crm",
)
# ---------- crm.expediente_folio_counters ----------
# PK compuesta (tenant, company, period): es la fila sobre la que serializa el
# INSERT ... ON CONFLICT DO UPDATE del asignador. Sin esa PK el upsert no tiene sobre qué
# detectar el conflicto y dos altas simultáneas darían el mismo folio.
op.create_table(
"expediente_folio_counters",
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("period", sa.String(length=7), nullable=False),
sa.Column("last_seq", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.PrimaryKeyConstraint("tenant_id", "company_id", "period"),
schema="crm",
)
op.create_foreign_key(
"fk_crm_expediente_folio_counters_tenant_id", "expediente_folio_counters", "tenants",
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
)
# ---------- Espejo de EFC en las dos tablas de documentos ----------
for tabla, schema in _TABLAS_CON_ESPEJO:
for columna in _columnas_espejo_efc():
op.add_column(tabla, columna, schema=schema)
op.create_index(
f"ix_{schema}_{tabla}_expediente_id", tabla, ["expediente_id"], schema=schema
)
op.create_index(
f"ix_{schema}_{tabla}_efc_document_ref", tabla, ["efc_document_ref"], schema=schema
)
def downgrade() -> None:
for tabla, schema in reversed(_TABLAS_CON_ESPEJO):
op.drop_index(f"ix_{schema}_{tabla}_efc_document_ref", table_name=tabla, schema=schema)
op.drop_index(f"ix_{schema}_{tabla}_expediente_id", table_name=tabla, schema=schema)
for columna in reversed(_columnas_espejo_efc()):
op.drop_column(tabla, columna.name, schema=schema)
op.drop_constraint(
"fk_crm_expediente_folio_counters_tenant_id", "expediente_folio_counters",
schema="crm", type_="foreignkey",
)
op.drop_table("expediente_folio_counters", schema="crm")
op.drop_constraint("fk_crm_expedientes_account_id", "expedientes", schema="crm", type_="foreignkey")
op.drop_constraint("fk_crm_expedientes_service_request_id", "expedientes", schema="crm", type_="foreignkey")
op.drop_constraint("fk_crm_expedientes_tenant_id", "expedientes", schema="crm", type_="foreignkey")
op.drop_index("ix_crm_expedientes_account_id", table_name="expedientes", schema="crm")
op.drop_index("ix_crm_expedientes_service_request_id", table_name="expedientes", schema="crm")
op.drop_index("ix_crm_expedientes_company_id", table_name="expedientes", schema="crm")
op.drop_index("ix_crm_expedientes_tenant_id", table_name="expedientes", schema="crm")
op.drop_index("ix_crm_expedientes_status", table_name="expedientes", schema="crm")
op.drop_index("ix_crm_expedientes_folio", table_name="expedientes", schema="crm")
op.drop_index("ix_crm_expedientes_id", table_name="expedientes", schema="crm")
op.drop_table("expedientes", schema="crm")

View File

@@ -0,0 +1,44 @@
"""ampliar crm.addresses.country a 3 (país ISO alfa-3 del catálogo)
Revision ID: e7f8a9b0c1d2
Revises: e6f7a8b9c0d1
Create Date: 2026-07-22 00:30:00.000000
El catálogo de País usa códigos ISO 3166 alfa-3 (MEX, USA, …). La columna
addresses.country era String(2); se amplía a String(3) para almacenarlos.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e7f8a9b0c1d2"
down_revision: Union[str, None] = "e6f7a8b9c0d1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.alter_column(
"addresses", "country",
type_=sa.String(length=3),
existing_type=sa.String(length=2),
existing_nullable=True,
server_default=sa.text("'MEX'"),
schema=SCHEMA,
)
def downgrade() -> None:
# Trunca a 2 chars por si hay códigos alfa-3 guardados (rollback de dev).
op.execute("UPDATE crm.addresses SET country = left(country, 2) WHERE length(country) > 2")
op.alter_column(
"addresses", "country",
type_=sa.String(length=2),
existing_type=sa.String(length=3),
existing_nullable=True,
server_default=sa.text("'MX'"),
schema=SCHEMA,
)

View File

@@ -0,0 +1,25 @@
"""Medio de contacto preferido en el prospecto (lead)
Revision ID: f0a1b2c3d4e5
Revises: e4f5a6b7c8d9
Create Date: 2026-08-07 01:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f0a1b2c3d4e5"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)

View File

@@ -1,113 +0,0 @@
"""Outbox del carril CRM -> EFC: crm.efc_sync_outbox (expedientes) y crm.efc_file_outbox (archivos).
Revision ID: f7a8b9c0d1e2
Revises: e6f7a8b9c0d1
Create Date: 2026-08-07 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f7a8b9c0d1e2"
down_revision: Union[str, None] = "e6f7a8b9c0d1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ---------- crm.efc_sync_outbox: metadatos (alta del provisional y completado) ----------
op.create_table(
"efc_sync_outbox",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("expediente_ref", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
schema="crm",
)
op.create_index("ix_crm_efc_sync_outbox_status", "efc_sync_outbox", ["status"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_kind_status", "efc_sync_outbox", ["kind", "status"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_expediente_ref", "efc_sync_outbox", ["expediente_ref"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", ["tenant_id"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_company_id", "efc_sync_outbox", ["company_id"], schema="crm")
op.create_foreign_key(
"fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", "tenants",
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
)
# ---------- crm.efc_file_outbox: archivos ----------
op.create_table(
"efc_file_outbox",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
sa.Column("kind", sa.String(length=30), nullable=False),
sa.Column("s3_key", sa.String(length=1024), nullable=False),
sa.Column("file_name", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=100), nullable=True),
sa.Column("efc_tipo", sa.String(length=40), nullable=False),
# La pareja (tabla, id) desambigua entre las DOS secuencias de documentos del CRM:
# crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten.
sa.Column("source_table", sa.String(length=30), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=True),
sa.Column("crm_document_ref", sa.String(length=64), nullable=True),
sa.Column("expediente_ref", sa.Integer(), nullable=False),
sa.Column("delete_local", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("efc_document_id", sa.String(length=36), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
schema="crm",
)
op.create_index("ix_crm_efc_file_outbox_status", "efc_file_outbox", ["status"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_kind_status", "efc_file_outbox", ["kind", "status"], schema="crm")
# Índice de la guarda _ya_entregado, que es lo que se consulta en cada encolado.
op.create_index("ix_crm_efc_file_outbox_source", "efc_file_outbox", ["source_table", "source_id"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", ["expediente_ref"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_tenant_id", "efc_file_outbox", ["tenant_id"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_company_id", "efc_file_outbox", ["company_id"], schema="crm")
op.create_foreign_key(
"fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", "tenants",
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
)
op.create_foreign_key(
"fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", "expedientes",
["expediente_ref"], ["id"], source_schema="crm", referent_schema="crm",
)
def downgrade() -> None:
op.drop_constraint("fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", schema="crm", type_="foreignkey")
op.drop_constraint("fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", schema="crm", type_="foreignkey")
op.drop_index("ix_crm_efc_file_outbox_company_id", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_tenant_id", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_expediente_ref", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_source", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_kind_status", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_status", table_name="efc_file_outbox", schema="crm")
op.drop_table("efc_file_outbox", schema="crm")
op.drop_constraint("fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", schema="crm", type_="foreignkey")
op.drop_index("ix_crm_efc_sync_outbox_company_id", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_tenant_id", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_expediente_ref", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_kind_status", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_status", table_name="efc_sync_outbox", schema="crm")
op.drop_table("efc_sync_outbox", schema="crm")

View File

@@ -0,0 +1,131 @@
"""crm rates: tarifarios (rate_sheets/lanes/breaks/charges)
Revision ID: f8a9b0c1d2e3
Revises: e7f8a9b0c1d2
Create Date: 2026-07-27 00:00:00.000000
Módulo Tarifario: base de costos para Cotizaciones (import por Excel + motor de costeo).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f8a9b0c1d2e3"
down_revision: Union[str, None] = "e7f8a9b0c1d2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def _scoped() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
]
def _idx(table: str) -> None:
op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA)
def upgrade() -> None:
# ----- rate_sheets -----
op.create_table(
"rate_sheets",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("mode", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=True, server_default=sa.text("'USD'")),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_to", sa.Date(), nullable=True),
sa.Column("default_origin", sa.String(length=20), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
sa.Column("source_file", sa.String(length=512), nullable=True),
sa.Column("source_url", sa.String(length=1024), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]),
schema=SCHEMA,
)
_idx("rate_sheets")
op.create_index("ix_crm_rate_sheets_mode", "rate_sheets", ["mode"], schema=SCHEMA)
op.create_index("ix_crm_rate_sheets_supplier_id", "rate_sheets", ["supplier_id"], schema=SCHEMA)
# ----- rate_lanes -----
op.create_table(
"rate_lanes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rate_sheet_id", sa.Integer(), nullable=False),
sa.Column("origin", sa.String(length=20), nullable=True),
sa.Column("destination", sa.String(length=20), nullable=True),
sa.Column("region", sa.String(length=60), nullable=True),
sa.Column("equipment_type", sa.String(length=20), nullable=True),
sa.Column("rate_unit", sa.String(length=20), nullable=True),
sa.Column("min_charge", sa.Numeric(precision=14, scale=4), nullable=True),
sa.Column("flat_rate", sa.Numeric(precision=14, scale=4), nullable=True),
sa.Column("transit_days", sa.Integer(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
schema=SCHEMA,
)
_idx("rate_lanes")
op.create_index("ix_crm_rate_lanes_rate_sheet_id", "rate_lanes", ["rate_sheet_id"], schema=SCHEMA)
op.create_index("ix_crm_rate_lanes_origin", "rate_lanes", ["origin"], schema=SCHEMA)
op.create_index("ix_crm_rate_lanes_destination", "rate_lanes", ["destination"], schema=SCHEMA)
# ----- rate_breaks -----
op.create_table(
"rate_breaks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rate_lane_id", sa.Integer(), nullable=False),
sa.Column("from_qty", sa.Numeric(precision=12, scale=3), nullable=False, server_default=sa.text("0")),
sa.Column("rate", sa.Numeric(precision=14, scale=4), nullable=False, server_default=sa.text("0")),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
schema=SCHEMA,
)
_idx("rate_breaks")
op.create_index("ix_crm_rate_breaks_rate_lane_id", "rate_breaks", ["rate_lane_id"], schema=SCHEMA)
# ----- rate_charges -----
op.create_table(
"rate_charges",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rate_sheet_id", sa.Integer(), nullable=True),
sa.Column("rate_lane_id", sa.Integer(), nullable=True),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("charge_type", sa.String(length=20), nullable=False, server_default=sa.text("'fijo'")),
sa.Column("value", sa.Numeric(precision=14, scale=4), nullable=True),
sa.Column("condition", sa.Text(), nullable=True),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
schema=SCHEMA,
)
_idx("rate_charges")
def downgrade() -> None:
op.drop_table("rate_charges", schema=SCHEMA)
op.drop_table("rate_breaks", schema=SCHEMA)
op.drop_table("rate_lanes", schema=SCHEMA)
op.drop_table("rate_sheets", schema=SCHEMA)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, text
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
@@ -31,39 +31,3 @@ class TenantScopedMixin:
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
class EfcDocumentRefMixin:
"""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 su propio ciclo de vida y sobrevive a un borrado cuya fila ya no está.
Es un mixin y no diez columnas copiadas en dos modelos porque las dos tablas tienen que
describir el mismo espejo: si divergen, la UI pinta un badge distinto según de dónde venga el
documento y nadie entiende por qué.
"""
expediente_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
# {TABLA}-{company_id}-{row_id}, p. ej. SHPDOC-1-4471. Texto y no un entero porque el CRM tiene
# dos tablas de documentos con secuencias independientes: crm.documents.id = 5 y
# ops.shipment_documents.id = 5 coexisten, así que un entero solo sería ambiguo entre ellas.
efc_document_ref: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
efc_document_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
# PENDING | SYNCED | FAILED
efc_sync_state: Mapped[str | None] = mapped_column(
String(20), nullable=True, server_default=text("'PENDING'")
)
efc_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
efc_attempts: Mapped[int | None] = mapped_column(Integer, nullable=True, server_default=text("0"))
content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -36,6 +36,12 @@ class TokenResponseDTO(BaseModel):
tenant: Optional["TenantInfoDTO"] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
# Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED.
# Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y
# que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token
# de arriba sigue siendo el de Keycloak (para llamadas al Hub).
session_token: Optional[str] = None
session_id: Optional[str] = None
class Config:
json_schema_extra = {
@@ -52,6 +58,12 @@ class RefreshTokenRequestDTO(BaseModel):
"""DTO para solicitud de refresh token"""
refresh_token: str = Field(..., description="Refresh token")
# Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el
# inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el
# refresh del token KC contra el Hub falla ("Token is not active" del relay).
session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)")
# session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser).
session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)")
class UserInfoResponseDTO(BaseModel):

View File

@@ -24,6 +24,12 @@ from .dto import (
)
from .service import AuthService
import logging
from typing import Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@@ -402,10 +408,16 @@ async def dev_login():
@router.get("/my-companies")
async def get_my_companies(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Retorna las compañías accesibles para el usuario actual.
STUB: implementa con tu modelo de compañías.
Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con
el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se
garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se
resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario).
En dev-local retorna una compañía ficticia para que el dashboard funcione.
"""
from core.config import settings
@@ -415,8 +427,239 @@ async def get_my_companies(
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"name": "Empresa Dev Local",
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"rfc": None,
"logo": None,
"is_active": True,
}]
# Implementa aquí la consulta real a tu tabla de compañías.
from core.security import (
resolve_effective_tenant_id_from_user,
_ensure_user_tenant_for_company,
)
from api.v1.modules.core.tenants.models import Tenant
from sqlalchemy import text
user_id = current_user.get("sub") or current_user.get("id")
tenant_id = resolve_effective_tenant_id_from_user(current_user)
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
# defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de
# su tenant. Así cualquier usuario del mismo tenant (misma organización del
# Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se
# asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin
# asigna el rol; el primer usuario recibe super_admin vía /permissions/me).
if tenant_id:
tenant_id = int(tenant_id)
company_ids = [
int(r[0])
for r in db.execute(
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
{"tid": tenant_id},
).fetchall()
]
if not company_ids:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
default_name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
)
created = db.execute(
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
{"tid": tenant_id, "name": default_name},
).fetchone()
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
company_ids = [int(created[0])]
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
# Auto-ligado por tenant (solo membresía, sin rol).
if user_id:
for cid in company_ids:
try:
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
except Exception as exc:
logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc)
# 2) Compañías por MEMBRESÍA (user_tenants user_company_roles) → funciona
# también para hub_admin sin tenant en el token: verá las compañías que creó
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
if not user_id:
return []
rows = db.execute(
text(
"""
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug
FROM a76.company c
LEFT JOIN core.tenants t ON t.id = c.tenant_id
WHERE c.id IN (
SELECT company_id FROM core.user_tenants
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
UNION
SELECT company_id FROM core.user_company_roles
WHERE user_id = :uid AND is_active
)
ORDER BY c.id
"""
),
{"uid": str(user_id)},
).fetchall()
return [
{
"id": int(r[0]),
"name": r[1] or "Empresa",
"tenant_id": int(r[4]),
"tenant_name": r[5],
"tenant_slug": r[6],
"rfc": r[2],
"logo": r[3],
"is_active": True,
}
for r in rows
]
class _CreateCompanyDTO(BaseModel):
name: str
tenant_id: int
rfc: Optional[str] = None
async def _sync_tenants_from_hub(request: Request, db: Session) -> None:
"""
Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y
los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los
tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías.
Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no
responde, no bloquea (se devuelven los tenants ya sincronizados).
"""
import httpx
from sqlalchemy import text as _text
from core.config import settings
from core import session_store
from api.v1.modules.core.tenants.models import Tenant, TenantType
sid = request.cookies.get("crm_sid") if request else None
kc_token = None
if sid:
sess = session_store.get_session(sid)
kc_token = (sess or {}).get("access_token")
if not kc_token:
return
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(
f"{settings.HUB_URL}api/v1/hub/tenants",
headers={"Authorization": f"Bearer {kc_token}"},
)
if r.status_code != 200:
logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code)
return
payload = r.json()
items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or [])
for t in items:
tid = t.get("id")
if tid is None:
continue
name = t.get("name") or t.get("display_name") or t.get("slug")
slug = t.get("slug") or f"tenant-{tid}"
existing = db.query(Tenant).filter(Tenant.id == int(tid)).first()
if existing:
if name and existing.name != name:
existing.name = name
else:
db.add(Tenant(
id=int(tid), name=name or slug, slug=slug,
keycloak_realm=slug, type=TenantType.SHARED, is_active=True,
))
db.commit()
db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"))
db.commit()
except Exception as exc:
logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc)
try:
db.rollback()
except Exception:
pass
@router.get("/assignable-tenants")
async def assignable_tenants(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace;
aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario
con tenant ve el suyo.
"""
from api.v1.modules.core.tenants.models import Tenant
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
if is_hub_admin(current_user):
# Sincroniza automáticamente los tenants del Workspace antes de listar.
await _sync_tenants_from_hub(request, db)
rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows]
tid = resolve_effective_tenant_id_from_user(current_user)
if tid:
t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712
return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else []
return []
@router.post("/companies", status_code=201)
async def create_company(
data: _CreateCompanyDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
"""
from sqlalchemy import text as _text
from api.v1.modules.core.tenants.models import Tenant
from core.security import (
resolve_effective_tenant_id_from_user,
is_hub_admin,
_ensure_user_tenant_for_company,
)
name = (data.name or "").strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
tid = int(data.tenant_id)
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
# Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant;
# un usuario ligado a un tenant, solo en el suyo.
if not is_hub_admin(current_user):
own = resolve_effective_tenant_id_from_user(current_user)
if own is None or int(own) != tid:
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
created = db.execute(
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
{"t": tid, "n": name, "r": (data.rfc or None)},
).fetchone()
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
cid = int(created[0])
user_id = current_user.get("sub") or current_user.get("id")
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
except Exception as exc:
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}

View File

@@ -213,55 +213,160 @@ class AuthService:
logger.error(f"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Decodifica una sesión local del CRM (HS256) verificando la firma pero
SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims
o None si la firma no valida o no es una sesión local del CRM.
"""
if not session_token:
return None
try:
claims = jwt.decode(
session_token,
settings.SECRET_KEY,
algorithms=["HS256"],
options={"verify_exp": False},
)
except JWTError:
return None
if not claims.get("crm_session") or claims.get("source") != "local":
return None
return claims
def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Construye los claims de la sesión local a partir del token KC (decode)."""
kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
claims: Dict[str, Any] = dict(kc_claims)
# tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
if data.get("tenant_id") is not None:
claims["tenant_id"] = data.get("tenant_id")
if data.get("tenant_slug") is not None:
claims["tenant_slug"] = data.get("tenant_slug")
return claims
async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae
is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no
responde, se cae al decode del token KC. Así la sesión local sabe si el
usuario es hub_admin sin volver a consultar al Hub en cada request.
"""
from core.security import verify_token
claims: Dict[str, Any] = {}
try:
info = await verify_token(data.get("access_token", ""))
if isinstance(info, dict):
claims = dict(info)
except Exception as exc:
logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc)
if not claims:
return self._session_claims_from_kc(data)
# tenant_id/tenant_slug explícitos del Hub tienen precedencia.
if data.get("tenant_id") is not None:
claims["tenant_id"] = data.get("tenant_id")
if data.get("tenant_slug") is not None:
claims["tenant_slug"] = data.get("tenant_slug")
return claims
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando el Hub
Refresca la sesión.
- Intenta el refresh del token KC contra el Hub (comportamiento histórico).
- Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM
(patrón SIWEB) que la app usa como bearer y que dura por inactividad, de
modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s).
- Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se
RESPETA la revocación central de Keycloak (sin re-emisión de fallback).
"""
from datetime import datetime, timezone
session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False))
prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None
prev_sst = prev_claims.get("sst") if prev_claims else None
prev_session_id = refresh_data.session_id if session_enabled else None
# Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que
# mande el cliente (puede estar desactualizado). Fail-silent.
kc_refresh = refresh_data.refresh_token
if session_enabled and prev_session_id:
from core import session_store
sess = session_store.get_session(prev_session_id)
if sess and sess.get("refresh_token"):
kc_refresh = sess["refresh_token"]
# ── Intento de refresh del token KC contra el Hub ────────────────────────
kc_ok = False
data: Optional[Dict[str, Any]] = None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json=refresh_data.model_dump()
json={"refresh_token": kc_refresh},
)
if response.status_code == 200:
kc_ok = response.status_code == 200
if kc_ok:
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
else:
logger.warning("Hub rechazó el refresh (status %s)", response.status_code)
except Exception as exc:
logger.warning("Hub inalcanzable en refresh: %s", exc)
kc_ok = False
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
# ── Camino feliz: el Hub renovó el token KC ──────────────────────────────
if kc_ok and data is not None:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", ""))
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)},
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
workspace_profile = None
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
resp = TokenResponseDTO(**data)
if session_enabled:
from core import local_session, session_store
start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
claims = await self._session_claims(data)
new_access = data.get("access_token", "")
new_refresh = data.get("refresh_token", "")
# Reutiliza la sesión de valkey si ya existía; si no, la crea.
if prev_session_id and session_store.get_session(prev_session_id):
session_store.update_session_tokens(prev_session_id, new_access, new_refresh)
resp.session_id = prev_session_id
else:
resp.session_id = session_store.create_session(new_access, new_refresh, start)
resp.session_token = local_session.mint_session_token(claims, session_start=start)
return resp
# El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación
# central de Keycloak (no hay re-emisión local de fallback). El usuario
# re-entra por el App Launcher. La sesión local de larga duración evita el
# bucle: el refresh solo se intenta al expirar la sesión local por
# inactividad (idle), no cada ~60s como con el token KC crudo.
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""

View File

@@ -37,13 +37,33 @@ async def create_invite(
required_permissions=["user.create"],
)
# tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el
# token), se resuelve desde la compañía destino (a76.company → core.tenants).
tenant_slug: str = current_user.get("tenant_slug") or ""
if not tenant_slug:
from sqlalchemy import text as _text
row = db.execute(
_text(
"SELECT t.slug FROM a76.company c "
"JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c"
),
{"c": data.company_id},
).first()
if row and row[0]:
tenant_slug = row[0]
created_by: str = current_user.get("sub") or ""
# El invite se crea en el Hub: se necesita el token KC (la sesión local no la
# acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta.
from core.hub_token import get_hub_access_token
kc_token = await get_hub_access_token(request)
service = InviteService(db)
return await service.create_invite(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
user_access_token=credentials.credentials,
user_access_token=kc_token or credentials.credentials,
)

View File

@@ -53,12 +53,17 @@ async def get_user_statistics(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
from core.hub_token import get_hub_access_token
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
kc_token = await get_hub_access_token(request)
if kc_token:
token = kc_token
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
@@ -84,12 +89,19 @@ async def list_users(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
# El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta.
# Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado.
from core.hub_token import get_hub_access_token
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
kc_token = await get_hub_access_token(request)
if kc_token:
token = kc_token
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)

View File

@@ -13,15 +13,18 @@ class AccountBase(BaseModel):
record_type: str = Field("cliente", max_length=20) # cliente | prospecto
person_type: str | None = Field(None, max_length=10) # fisica | moral
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str = Field("active", max_length=20) # active | inactive
# Comercial
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
preferred_contact_other: str | None = Field(None, max_length=120)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
commercial_observations: str | None = None # observaciones generales
# Fiscal
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
@@ -36,7 +39,7 @@ class AccountBase(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3)
# Observaciones
notes: str | None = None
internal_notes: str | None = None
@@ -55,14 +58,17 @@ class AccountUpdate(BaseModel):
record_type: str | None = Field(None, max_length=20)
person_type: str | None = Field(None, max_length=10)
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
preferred_contact_other: str | None = Field(None, max_length=120)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
commercial_observations: str | None = None
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
payment_method: str | None = Field(None, max_length=60)
@@ -75,7 +81,7 @@ class AccountUpdate(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)

View File

@@ -31,6 +31,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# Tipo de persona: fisica | moral
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro"
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Estatus: active | inactive
@@ -39,12 +40,15 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# ----- Información comercial -----
# Clasificación: importador | exportador | ambos
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
# Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Texto libre cuando el medio de contacto es "otro"
preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
# ----- Información fiscal -----
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
@@ -62,7 +66,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales

View File

@@ -14,7 +14,7 @@ class AddressBase(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais)
reference_notes: str | None = None
is_primary: bool = False
@@ -32,7 +32,7 @@ class AddressUpdate(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
reference_notes: str | None = None
is_primary: bool | None = None

View File

@@ -29,7 +29,8 @@ class Address(Base, TenantScopedMixin, TimestampMixin):
neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True) # colonia
postal_code: Mapped[str | None] = mapped_column(String(10), nullable=True) # código postal
city: Mapped[str | None] = mapped_column(String(120), nullable=True) # municipio
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado (código catálogo)
# País como código ISO 3166 alfa-3 del catálogo (p. ej. MEX). Ampliado de 2→3.
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
reference_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # referencias
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
reference: str | None
account_id: int | None
title: str | None
stage: str
status: str
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class CaseTimelineEvent(BaseModel):
kind: str # oportunidad | solicitud | cotizacion | operacion | factura
id: int
reference: str | None = None
status: str | None = None
created_at: datetime
url: str
class CaseWithTimeline(CaseResponse):
timeline: list[CaseTimelineEvent] = []

View File

@@ -0,0 +1,28 @@
from sqlalchemy import ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Case(Base, TenantScopedMixin, TimestampMixin):
"""Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización →
Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia.
Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``.
"""
__tablename__ = "cases"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio EXP...
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado
stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'"))
# abierto | cerrado
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import CaseResponse, CaseWithTimeline
router = APIRouter()
def _with_timeline(db, case) -> CaseWithTimeline:
data = CaseWithTimeline.model_validate(case)
data.timeline = service.build_timeline(db, case) # type: ignore[assignment]
return data
@router.get("/cases", response_model=list[CaseResponse])
def list_cases(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
account_id: int | None = Query(None),
stage: str | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_cases(db, current_user["tenant_id"], company_id, search, account_id, stage)
@router.get("/cases/by-ref/{reference}", response_model=CaseWithTimeline)
def get_case_by_ref(
reference: str,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Expediente + historia completa por su referencia (para UI y otros sistemas)."""
case = service.get_case_by_reference(db, reference, current_user["tenant_id"], company_id)
return _with_timeline(db, case)
@router.get("/cases/{case_id}", response_model=CaseWithTimeline)
def get_case(
case_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
case = service.get_case(db, case_id, current_user["tenant_id"], company_id)
return _with_timeline(db, case)

View File

@@ -0,0 +1,109 @@
"""Lógica del Expediente: minteo del folio, avance de etapa y armado del timeline."""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..common.folios import next_folio
from .models import Case
# Orden de etapas (solo se avanza, nunca retrocede)
STAGE_ORDER = ["oportunidad", "solicitud", "cotizacion", "operacion", "facturacion", "cerrado"]
def create_case(
db: Session, tenant_id: int, company_id: int, *, account_id: int | None = None,
title: str | None = None, stage: str = "oportunidad", user_id: str | None = None,
) -> Case:
"""Mintea un expediente con folio EXP... (sin commit; lo confirma quien lo invoca)."""
case = Case(
reference=next_folio(db, tenant_id, company_id, "EXP", None, with_direction=False),
account_id=account_id, title=title, stage=stage, status="abierto",
tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id,
)
db.add(case)
db.flush()
return case
def advance_stage(db: Session, case_id: int | None, stage: str) -> None:
"""Avanza la etapa del expediente si la nueva es posterior a la actual."""
if not case_id or stage not in STAGE_ORDER:
return
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
return
current = case.stage if case.stage in STAGE_ORDER else "oportunidad"
if STAGE_ORDER.index(stage) > STAGE_ORDER.index(current):
case.stage = stage
def get_cases(
db: Session, tenant_id: int, company_id: int, search: str | None = None,
account_id: int | None = None, stage: str | None = None,
) -> list[Case]:
q = db.query(Case).filter(
Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None),
)
if account_id is not None:
q = q.filter(Case.account_id == account_id)
if stage:
q = q.filter(Case.stage == stage)
if search:
q = q.filter(Case.reference.ilike(f"%{search}%"))
return q.order_by(Case.created_at.desc()).all()
def get_case(db: Session, case_id: int, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.id == case_id, Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def get_case_by_reference(db: Session, reference: str, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.reference == reference, Case.tenant_id == tenant_id, Case.company_id == company_id,
Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def build_timeline(db: Session, case: Case) -> list[dict]:
"""Devuelve la historia del expediente: todas las entidades ligadas por case_id,
en orden cronológico. Un único lookup para la UI y para otros sistemas."""
# Import local para evitar ciclos de importación entre módulos.
from ..opportunities.models import Opportunity
from ..quotes.models import Quote
from ..service_requests.models import ServiceRequest
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.ops.shipments.models import Shipment
events: list[dict] = []
specs = [
("oportunidad", Opportunity, "/dashboard/crm/oportunidades"),
("solicitud", ServiceRequest, "/dashboard/crm/solicitudes"),
("cotizacion", Quote, "/dashboard/crm/cotizaciones"),
("operacion", Shipment, "/dashboard/ops/embarques"),
("factura", Invoice, "/dashboard/fin/facturas"),
]
for kind, model, base_url in specs:
rows = db.query(model).filter(model.case_id == case.id, model.deleted_at.is_(None)).all()
for r in rows:
events.append({
"kind": kind,
"id": r.id,
"reference": getattr(r, "reference", None),
"status": getattr(r, "status", None),
"created_at": r.created_at,
"url": f"{base_url}/{r.id}",
})
events.sort(key=lambda e: e["created_at"])
return events

View File

@@ -0,0 +1,46 @@
"""Schemas (DTOs) de los catálogos de referencia del CRM."""
from pydantic import BaseModel, ConfigDict, Field
class CatalogItemBase(BaseModel):
code: str = Field(..., max_length=64)
label: str = Field(..., max_length=255)
parent_catalog: str | None = Field(None, max_length=60)
parent_code: str | None = Field(None, max_length=64)
sort_order: int = 0
is_active: bool = True
class CatalogItemCreate(CatalogItemBase):
pass
class CatalogItemUpdate(BaseModel):
"""PATCH: todos los campos opcionales."""
code: str | None = Field(None, max_length=64)
label: str | None = Field(None, max_length=255)
parent_code: str | None = Field(None, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
class CatalogItemResponse(CatalogItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
catalog: str
tenant_id: int | None
is_system: bool
extra: dict | None = None # metadata (ej. dimensiones de un tipo de equipo)
class CatalogMeta(BaseModel):
"""Metadata de un catálogo para la pantalla de administración."""
catalog: str
label: str
scope: str # 'global' | 'tenant'
is_system: bool
count: int

View File

@@ -0,0 +1,54 @@
"""Modelo de catálogos de referencia del CRM (T2026-07-081/082).
Un único modelo genérico ``CatalogItem`` respalda todos los catálogos
(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo
(``catalog``) e identifica una opción por ``code`` (clave) + ``label``
(descripción que se visualiza).
Alcance:
- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos.
- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra).
Los catálogos dependientes (p. ej. Estado depende de País) usan
``parent_catalog`` + ``parent_code`` para filtrarse.
"""
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from core.database import Base
class CatalogItem(Base):
__tablename__ = "catalog_items"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(64), nullable=False)
label: Mapped[str] = mapped_column(String(255), nullable=False)
# Dependencia (Estado→País, Municipio→Estado, …)
parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True)
parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
# NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente).
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
# Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar).
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
extra: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)

View File

@@ -1,6 +1,10 @@
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).
from fastapi import APIRouter, Depends, Query
Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales
(Aduanasoft) y catálogos propios de cada cliente (tenant).
"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
@@ -8,7 +12,9 @@ from core.security import get_current_user
from ..accounts.models import Account
from ..suppliers.models import Supplier
from . import service as catalog_service
from .data import INCOTERMS, PARTICIPANT_ROLES
from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta
router = APIRouter()
@@ -31,6 +37,76 @@ def list_participant_roles(
return PARTICIPANT_ROLES
# ----------------------------------------------------------------------------
# Catálogos de referencia (CRUD) — T2026-07-081/082
# ----------------------------------------------------------------------------
@router.get("/catalogs", response_model=list[CatalogMeta])
def list_catalog_meta(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Lista los catálogos disponibles (global + del tenant) con su conteo."""
return catalog_service.list_meta(db, current_user["tenant_id"])
@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse])
def list_catalog_items(
catalog: str,
company_id: int = Query(..., description="Company ID"),
parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"),
include_inactive: bool = Query(False),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Opciones de un catálogo (global + del tenant), activas y ordenadas."""
return catalog_service.list_items(
db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive
)
@router.post(
"/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED
)
def create_catalog_item(
catalog: str,
data: CatalogItemCreate,
company_id: int = Query(..., description="Company ID"),
scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Inserta una opción en un catálogo."""
return catalog_service.create_item(db, catalog, data, current_user, scope=scope)
@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse)
def update_catalog_item(
catalog: str,
item_id: int,
data: CatalogItemUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Edita una opción de catálogo."""
return catalog_service.update_item(db, catalog, item_id, data, current_user)
@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_catalog_item(
catalog: str,
item_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Borra una opción de catálogo (los catálogos base del sistema no se borran)."""
catalog_service.delete_item(db, catalog, item_id, current_user)
@router.get("/participants")
def list_participants(
company_id: int = Query(..., description="Company ID"),

View File

@@ -0,0 +1,69 @@
"""Siembra de catálogos globales (Aduanasoft) del CRM.
Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se
puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno:
docker compose exec backend python -m api.v1.modules.crm.catalogs.seed
"""
import logging
from sqlalchemy.orm import Session
from .models import CatalogItem
from .seed_data import GLOBAL_CATALOGS
logger = logging.getLogger(__name__)
def seed_global_catalogs(db: Session) -> dict:
"""Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}."""
summary: dict[str, int] = {}
for catalog, meta in GLOBAL_CATALOGS.items():
is_system = bool(meta.get("is_system", False))
existing = {
row.code
for row in db.query(CatalogItem.code).filter(
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
)
}
added = 0
for order, item in enumerate(meta["items"]):
if item["code"] in existing:
continue
db.add(
CatalogItem(
catalog=catalog,
code=item["code"],
label=item["label"],
parent_catalog=item.get("parent_catalog"),
parent_code=item.get("parent_code"),
extra=item.get("extra"),
tenant_id=None,
sort_order=order,
is_active=True,
is_system=is_system,
)
)
added += 1
if added:
summary[catalog] = added
db.commit()
total = sum(summary.values())
logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary))
return summary
def _run() -> None:
from core.database import CoreSessionLocal
db = CoreSessionLocal()
try:
result = seed_global_catalogs(db)
print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)")
finally:
db.close()
if __name__ == "__main__":
_run()

View File

@@ -0,0 +1,874 @@
"""Datos semilla de los catálogos de referencia del CRM.
SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra)
+ catálogos del módulo Tarifario. Globales con tenant_id NULL.
"""
GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
'is_system': True,
'items': [{'code': 'cliente', 'label': 'Cliente'}, {'code': 'prospecto', 'label': 'Prospecto'}]},
'tipo_persona': {'label': 'Tipo de persona',
'is_system': True,
'items': [{'code': 'fisica', 'label': 'Persona física'},
{'code': 'moral', 'label': 'Persona moral'}]},
'estatus': {'label': 'Estatus',
'is_system': True,
'items': [{'code': 'active', 'label': 'Activo'}, {'code': 'inactive', 'label': 'Inactivo'}]},
'giro': {'label': 'Giro o industria',
'is_system': False,
'items': [{'code': 'importadora', 'label': 'Importadora'},
{'code': 'exportadora', 'label': 'Exportadora'},
{'code': 'manufactura', 'label': 'Manufactura'},
{'code': 'comercializadora', 'label': 'Comercializadora'},
{'code': 'logistica', 'label': 'Logística y transporte'},
{'code': 'agencia_aduanal', 'label': 'Agencia aduanal'},
{'code': 'maquiladora', 'label': 'Maquiladora / IMMEX'},
{'code': 'servicios', 'label': 'Servicios'},
{'code': 'otro', 'label': 'Otro'}]},
'clasificacion_cliente': {'label': 'Clasificación del cliente',
'is_system': False,
'items': [{'code': 'importador', 'label': 'Importador'},
{'code': 'exportador', 'label': 'Exportador'},
{'code': 'importador_exportador', 'label': 'Importador/Exportador'}]},
'medio_contacto': {'label': 'Medio de contacto preferido',
'is_system': False,
'items': [{'code': 'llamada', 'label': 'Llamada telefónica'},
{'code': 'correo', 'label': 'Correo electrónico'},
{'code': 'videoconferencia', 'label': 'Videoconferencia'},
{'code': 'whatsapp', 'label': 'WhatsApp'},
{'code': 'otro', 'label': 'Otro'}]},
'idioma': {'label': 'Idioma',
'is_system': False,
'items': [{'code': 'es', 'label': 'Español'},
{'code': 'en', 'label': 'Inglés'},
{'code': 'zh', 'label': 'Chino (mandarín)'},
{'code': 'pt', 'label': 'Portugués'},
{'code': 'fr', 'label': 'Francés'},
{'code': 'de', 'label': 'Alemán'},
{'code': 'ja', 'label': 'Japonés'},
{'code': 'ko', 'label': 'Coreano'},
{'code': 'it', 'label': 'Italiano'},
{'code': 'otro', 'label': 'Otro'}]},
'regimen_fiscal': {'label': 'Régimen fiscal',
'is_system': False,
'items': [{'code': 'fisica', 'label': 'Persona física'},
{'code': 'moral', 'label': 'Persona moral'}]},
'uso_cfdi': {'label': 'Uso de CFDI (SAT)',
'is_system': True,
'items': [{'code': 'G01', 'label': 'Adquisición de mercancías'},
{'code': 'G02', 'label': 'Devoluciones, descuentos o bonificaciones'},
{'code': 'G03', 'label': 'Gastos en general'},
{'code': 'I01', 'label': 'Construcciones'},
{'code': 'I02', 'label': 'Mobiliario y equipo de oficina por inversiones'},
{'code': 'I03', 'label': 'Equipo de transporte'},
{'code': 'I04', 'label': 'Equipo de cómputo y accesorios'},
{'code': 'I05', 'label': 'Dados, troqueles, moldes, matrices y herramental'},
{'code': 'I06', 'label': 'Comunicaciones telefónicas'},
{'code': 'I07', 'label': 'Comunicaciones satelitales'},
{'code': 'I08', 'label': 'Otra maquinaria y equipo'},
{'code': 'D01', 'label': 'Honorarios médicos, dentales y gastos hospitalarios'},
{'code': 'D02', 'label': 'Gastos médicos por incapacidad o discapacidad'},
{'code': 'D03', 'label': 'Gastos funerales'},
{'code': 'D04', 'label': 'Donativos'},
{'code': 'D05', 'label': 'Intereses por créditos hipotecarios'},
{'code': 'D06', 'label': 'Aportaciones voluntarias al SAR'},
{'code': 'D07', 'label': 'Primas por seguros de gastos médicos'},
{'code': 'D08', 'label': 'Gastos de transportación escolar obligatoria'},
{'code': 'D09', 'label': 'Depósitos en cuentas para el ahorro'},
{'code': 'D10', 'label': 'Pagos por servicios educativos (colegiaturas)'},
{'code': 'S01', 'label': 'Sin efectos fiscales'},
{'code': 'CP01', 'label': 'Pagos'},
{'code': 'CN01', 'label': 'Nómina'},
{'code': 'P01', 'label': 'Por definir'}]},
'forma_pago': {'label': 'Forma de pago (SAT)',
'is_system': True,
'items': [{'code': '1', 'label': 'Efectivo'},
{'code': '2', 'label': 'Cheque nominativo'},
{'code': '3', 'label': 'Transferencia electrónica de fondos'},
{'code': '4', 'label': 'Tarjeta de crédito'},
{'code': '5', 'label': 'Monedero electrónico'},
{'code': '6', 'label': 'Dinero electrónico'},
{'code': '8', 'label': 'Vales de despensa'},
{'code': '12', 'label': 'Dación en pago'},
{'code': '13', 'label': 'Pago por subrogación'},
{'code': '14', 'label': 'Pago por consignación'},
{'code': '15', 'label': 'Condonación'},
{'code': '17', 'label': 'Compensación'},
{'code': '23', 'label': 'Novación'},
{'code': '24', 'label': 'Confusión'},
{'code': '25', 'label': 'Remisión de deuda'},
{'code': '26', 'label': 'Prescripción o caducidad'},
{'code': '27', 'label': 'A satisfacción del acreedor'},
{'code': '28', 'label': 'Tarjeta de débito'},
{'code': '29', 'label': 'Tarjeta de servicios'},
{'code': '30', 'label': 'Aplicación de anticipos'},
{'code': '31', 'label': 'Intermediario pagos'},
{'code': '99', 'label': 'Por definir'}]},
'metodo_pago': {'label': 'Método de pago (SAT)',
'is_system': True,
'items': [{'code': 'PPD', 'label': 'Pago en parcialidades o diferido'},
{'code': 'PUE', 'label': 'Pago en una sola exhibición'}]},
'moneda': {'label': 'Moneda (ISO 4217)',
'is_system': True,
'items': [{'code': 'CRC', 'label': 'Colón costarricense'},
{'code': 'CUC', 'label': 'Peso Convertible'},
{'code': 'CUP', 'label': 'Peso Cubano'},
{'code': 'CVE', 'label': 'Cabo Verde Escudo'},
{'code': 'CZK', 'label': 'Corona checa'},
{'code': 'DJF', 'label': 'Franco de Djibouti'},
{'code': 'DKK', 'label': 'Corona danesa'},
{'code': 'DOP', 'label': 'Peso Dominicano'},
{'code': 'DZD', 'label': 'Dinar argelino'},
{'code': 'EGP', 'label': 'Libra egipcia'},
{'code': 'ERN', 'label': 'Nakfa'},
{'code': 'ETB', 'label': 'Birr etíope'},
{'code': 'EUR', 'label': 'Euro'},
{'code': 'FJD', 'label': 'Dólar de Fiji'},
{'code': 'FKP', 'label': 'Libra malvinense'},
{'code': 'GBP', 'label': 'Libra Esterlina'},
{'code': 'GEL', 'label': 'Lari'},
{'code': 'GHS', 'label': 'Cedi de Ghana'},
{'code': 'GIP', 'label': 'Libra de Gibraltar'},
{'code': 'GMD', 'label': 'Dalasi'},
{'code': 'GNF', 'label': 'Franco guineano'},
{'code': 'GTQ', 'label': 'Quetzal'},
{'code': 'GYD', 'label': 'Dólar guyanés'},
{'code': 'HKD', 'label': 'Dolar De Hong Kong'},
{'code': 'HNL', 'label': 'Lempira'},
{'code': 'HRK', 'label': 'Kuna'},
{'code': 'HTG', 'label': 'Gourde'},
{'code': 'HUF', 'label': 'Florín'},
{'code': 'IDR', 'label': 'Rupia'},
{'code': 'ILS', 'label': 'Nuevo Shekel Israelí'},
{'code': 'INR', 'label': 'Rupia india'},
{'code': 'IQD', 'label': 'Dinar iraquí'},
{'code': 'IRR', 'label': 'Rial iraní'},
{'code': 'ISK', 'label': 'Corona islandesa'},
{'code': 'JMD', 'label': 'Dólar Jamaiquino'},
{'code': 'JOD', 'label': 'Dinar jordano'},
{'code': 'JPY', 'label': 'Yen'},
{'code': 'KES', 'label': 'Chelín keniano'},
{'code': 'KGS', 'label': 'Som'},
{'code': 'KHR', 'label': 'Riel'},
{'code': 'KMF', 'label': 'Franco Comoro'},
{'code': 'KPW', 'label': 'Corea del Norte ganó'},
{'code': 'KRW', 'label': 'Won'},
{'code': 'KWD', 'label': 'Dinar kuwaití'},
{'code': 'KYD', 'label': 'Dólar de las Islas Caimán'},
{'code': 'KZT', 'label': 'Tenge'},
{'code': 'LAK', 'label': 'Kip'},
{'code': 'LBP', 'label': 'Libra libanesa'},
{'code': 'LKR', 'label': 'Rupia de Sri Lanka'},
{'code': 'LRD', 'label': 'Dólar liberiano'},
{'code': 'LSL', 'label': 'Loti'},
{'code': 'LYD', 'label': 'Dinar libio'},
{'code': 'MAD', 'label': 'Dirham marroquí'},
{'code': 'MDL', 'label': 'Leu moldavo'},
{'code': 'MGA', 'label': 'Ariary malgache'},
{'code': 'MKD', 'label': 'Denar'},
{'code': 'MMK', 'label': 'Kyat'},
{'code': 'MNT', 'label': 'Tugrik'},
{'code': 'MOP', 'label': 'Pataca'},
{'code': 'MRO', 'label': 'Ouguiya'},
{'code': 'MUR', 'label': 'Rupia de Mauricio'},
{'code': 'MVR', 'label': 'Rupia'},
{'code': 'MWK', 'label': 'Kwacha'},
{'code': 'MXN', 'label': 'Peso Mexicano'},
{'code': 'MXV', 'label': 'México Unidad de Inversión (UDI)'},
{'code': 'MYR', 'label': 'Ringgit malayo'},
{'code': 'MZN', 'label': 'Mozambique Metical'},
{'code': 'NAD', 'label': 'Dólar de Namibia'},
{'code': 'NGN', 'label': 'Naira'},
{'code': 'NIO', 'label': 'Córdoba Oro'},
{'code': 'NOK', 'label': 'Corona noruega'},
{'code': 'NPR', 'label': 'Rupia nepalí'},
{'code': 'NZD', 'label': 'Dólar de Nueva Zelanda'},
{'code': 'OMR', 'label': 'Rial omaní'},
{'code': 'PAB', 'label': 'Balboa'},
{'code': 'PEN', 'label': 'Nuevo Sol'},
{'code': 'PGK', 'label': 'Kina'},
{'code': 'PHP', 'label': 'Peso filipino'},
{'code': 'PKR', 'label': 'Rupia de Pakistán'},
{'code': 'PLN', 'label': 'Zloty'},
{'code': 'PYG', 'label': 'Guaraní'},
{'code': 'QAR', 'label': 'Qatar Rial'},
{'code': 'RON', 'label': 'Leu rumano'},
{'code': 'RSD', 'label': 'Dinar serbio'},
{'code': 'RUB', 'label': 'Rublo ruso'},
{'code': 'RWF', 'label': 'Franco ruandés'},
{'code': 'SAR', 'label': 'Riyal saudí'},
{'code': 'SBD', 'label': 'Dólar de las Islas Salomón'},
{'code': 'SCR', 'label': 'Rupia de Seychelles'},
{'code': 'SDG', 'label': 'Libra sudanesa'},
{'code': 'SEK', 'label': 'Corona sueca'},
{'code': 'SGD', 'label': 'Dolar De Singapur'},
{'code': 'SHP', 'label': 'Libra de Santa Helena'},
{'code': 'SLL', 'label': 'Leona'},
{'code': 'SOS', 'label': 'Chelín somalí'},
{'code': 'SRD', 'label': 'Dólar de Suriname'},
{'code': 'SSP', 'label': 'Libra sudanesa Sur'},
{'code': 'STD', 'label': 'Dobra'},
{'code': 'SVC', 'label': 'Colon El Salvador'},
{'code': 'SYP', 'label': 'Libra Siria'},
{'code': 'SZL', 'label': 'Lilangeni'},
{'code': 'THB', 'label': 'Baht'},
{'code': 'TJS', 'label': 'Somoni'},
{'code': 'TMT', 'label': 'Turkmenistán nuevo manat'},
{'code': 'TND', 'label': 'Dinar tunecino'},
{'code': 'TOP', 'label': "Pa'anga"},
{'code': 'TRY', 'label': 'Lira turca'},
{'code': 'TTD', 'label': 'Dólar de Trinidad y Tobago'},
{'code': 'TWD', 'label': 'Nuevo dólar de Taiwán'},
{'code': 'TZS', 'label': 'Shilling tanzano'},
{'code': 'UAH', 'label': 'Hryvnia'},
{'code': 'UGX', 'label': 'Shilling de Uganda'},
{'code': 'USD', 'label': 'Dolar americano'},
{'code': 'USN', 'label': 'Dólar estadounidense (día siguiente)'},
{'code': 'UYI', 'label': 'Peso Uruguay en Unidades Indexadas (URUIURUI)'},
{'code': 'UYU', 'label': 'Peso Uruguayo'},
{'code': 'UZS', 'label': 'Uzbekistán Sum'},
{'code': 'VEF', 'label': 'Bolívar'},
{'code': 'VND', 'label': 'Dong'},
{'code': 'VUV', 'label': 'Vatu'},
{'code': 'WST', 'label': 'Tala'},
{'code': 'XAF', 'label': 'Franco CFA BEAC'},
{'code': 'XAG', 'label': 'Plata'},
{'code': 'XAU', 'label': 'Oro'},
{'code': 'XBA', 'label': 'Unidad de Mercados de Bonos Unidad Europea Composite (EURCO)'},
{'code': 'XBB', 'label': 'Unidad Monetaria de Bonos de Mercados Unidad Europea (UEM-6)'},
{'code': 'XBC', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 9 (UCE-9)'},
{'code': 'XBD', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 17 (UCE-17)'},
{'code': 'XCD', 'label': 'Dólar del Caribe Oriental'},
{'code': 'XDR', 'label': 'DEG (Derechos Especiales de Giro)'},
{'code': 'XOF', 'label': 'Franco CFA BCEAO'},
{'code': 'XPD', 'label': 'Paladio'},
{'code': 'XPF', 'label': 'Franco CFP'},
{'code': 'XPT', 'label': 'Platino'},
{'code': 'XSU', 'label': 'Sucre'},
{'code': 'XTS', 'label': 'Códigos reservados específicamente para propósitos de prueba'},
{'code': 'XUA', 'label': 'Unidad ADB de Cuenta'},
{'code': 'XXX',
'label': 'Los códigos asignados para las transacciones en que intervenga ninguna moneda'},
{'code': 'YER', 'label': 'Rial yemení'},
{'code': 'ZAR', 'label': 'Rand'},
{'code': 'ZMW', 'label': 'Kwacha zambiano'},
{'code': 'ZWL', 'label': 'Zimbabwe Dólar'},
{'code': 'NULL', 'label': 'NULL'}]},
'pais': {'label': 'País (ISO 3166)',
'is_system': True,
'items': [{'code': 'ABW', 'label': 'Aruba'},
{'code': 'AFG', 'label': 'Afganistán'},
{'code': 'AGO', 'label': 'Angola'},
{'code': 'AIA', 'label': 'Anguila'},
{'code': 'ALA', 'label': 'Islas Åland'},
{'code': 'ALB', 'label': 'Albania'},
{'code': 'AND', 'label': 'Andorra'},
{'code': 'ARE', 'label': 'Emiratos Árabes Unidos (Los)'},
{'code': 'ARG', 'label': 'Argentina'},
{'code': 'ARM', 'label': 'Armenia'},
{'code': 'ASM', 'label': 'Samoa Americana'},
{'code': 'ATA', 'label': 'Antártida'},
{'code': 'ATF', 'label': 'Territorios Australes Franceses (los)'},
{'code': 'ATG', 'label': 'Antigua y Barbuda'},
{'code': 'AUS', 'label': 'Australia'},
{'code': 'AUT', 'label': 'Austria'},
{'code': 'AZE', 'label': 'Azerbaiyán'},
{'code': 'BDI', 'label': 'Burundi'},
{'code': 'BEL', 'label': 'Bélgica'},
{'code': 'BEN', 'label': 'Benín'},
{'code': 'BES', 'label': 'Bonaire, San Eustaquio y Saba'},
{'code': 'BFA', 'label': 'Burkina Faso'},
{'code': 'BGD', 'label': 'Bangladés'},
{'code': 'BGR', 'label': 'Bulgaria'},
{'code': 'BHR', 'label': 'Baréin'},
{'code': 'BHS', 'label': 'Bahamas (las)'},
{'code': 'BIH', 'label': 'Bosnia y Herzegovina'},
{'code': 'BLM', 'label': 'San Bartolomé'},
{'code': 'BLR', 'label': 'Bielorrusia'},
{'code': 'BLZ', 'label': 'Belice'},
{'code': 'BMU', 'label': 'Bermudas'},
{'code': 'BOL', 'label': 'Bolivia, Estado Plurinacional de'},
{'code': 'BRA', 'label': 'Brasil'},
{'code': 'BRB', 'label': 'Barbados'},
{'code': 'BRN', 'label': 'Brunéi Darussalam'},
{'code': 'BTN', 'label': 'Bután'},
{'code': 'BVT', 'label': 'Isla Bouvet'},
{'code': 'BWA', 'label': 'Botsuana'},
{'code': 'CAF', 'label': 'República Centroafricana (la)'},
{'code': 'CAN', 'label': 'Canadá'},
{'code': 'CCK', 'label': 'Islas Cocos (Keeling)'},
{'code': 'CHE', 'label': 'Suiza'},
{'code': 'CHL', 'label': 'Chile'},
{'code': 'CHN', 'label': 'China'},
{'code': 'CIV', 'label': "Côte d'Ivoire"},
{'code': 'CMR', 'label': 'Camerún'},
{'code': 'COD', 'label': 'Congo (la República Democrática del)'},
{'code': 'COG', 'label': 'Congo'},
{'code': 'COK', 'label': 'Islas Cook (las)'},
{'code': 'COL', 'label': 'Colombia'},
{'code': 'COM', 'label': 'Comoras'},
{'code': 'CPV', 'label': 'Cabo Verde'},
{'code': 'CRI', 'label': 'Costa Rica'},
{'code': 'CUB', 'label': 'Cuba'},
{'code': 'CUW', 'label': 'Curaçao'},
{'code': 'CXR', 'label': 'Isla de Navidad'},
{'code': 'CYM', 'label': 'Islas Caimán (las)'},
{'code': 'CYP', 'label': 'Chipre'},
{'code': 'CZE', 'label': 'República Checa (la)'},
{'code': 'DEU', 'label': 'Alemania'},
{'code': 'DJI', 'label': 'Yibuti'},
{'code': 'DMA', 'label': 'Dominica'},
{'code': 'DNK', 'label': 'Dinamarca'},
{'code': 'DOM', 'label': 'República Dominicana (la)'},
{'code': 'DZA', 'label': 'Argelia'},
{'code': 'ECU', 'label': 'Ecuador'},
{'code': 'EGY', 'label': 'Egipto'},
{'code': 'ERI', 'label': 'Eritrea'},
{'code': 'ESH', 'label': 'Sahara Occidental'},
{'code': 'ESP', 'label': 'España'},
{'code': 'EST', 'label': 'Estonia'},
{'code': 'ETH', 'label': 'Etiopía'},
{'code': 'FIN', 'label': 'Finlandia'},
{'code': 'FJI', 'label': 'Fiyi'},
{'code': 'FLK', 'label': 'Islas Malvinas [Falkland] (las)'},
{'code': 'FRA', 'label': 'Francia'},
{'code': 'FRO', 'label': 'Islas Feroe (las)'},
{'code': 'FSM', 'label': 'Micronesia (los Estados Federados de)'},
{'code': 'GAB', 'label': 'Gabón'},
{'code': 'GBR', 'label': 'Reino Unido (el)'},
{'code': 'GEO', 'label': 'Georgia'},
{'code': 'GGY', 'label': 'Guernsey'},
{'code': 'GHA', 'label': 'Ghana'},
{'code': 'GIB', 'label': 'Gibraltar'},
{'code': 'GIN', 'label': 'Guinea'},
{'code': 'GLP', 'label': 'Guadalupe'},
{'code': 'GMB', 'label': 'Gambia (La)'},
{'code': 'GNB', 'label': 'Guinea-Bisáu'},
{'code': 'GNQ', 'label': 'Guinea Ecuatorial'},
{'code': 'GRC', 'label': 'Grecia'},
{'code': 'GRD', 'label': 'Granada'},
{'code': 'GRL', 'label': 'Groenlandia'},
{'code': 'GTM', 'label': 'Guatemala'},
{'code': 'GUF', 'label': 'Guayana Francesa'},
{'code': 'GUM', 'label': 'Guam'},
{'code': 'GUY', 'label': 'Guyana'},
{'code': 'HKG', 'label': 'Hong Kong'},
{'code': 'HMD', 'label': 'Isla Heard e Islas McDonald'},
{'code': 'HND', 'label': 'Honduras'},
{'code': 'HRV', 'label': 'Croacia'},
{'code': 'HTI', 'label': 'Haití'},
{'code': 'HUN', 'label': 'Hungría'},
{'code': 'IDN', 'label': 'Indonesia'},
{'code': 'IMN', 'label': 'Isla de Man'},
{'code': 'IND', 'label': 'India'},
{'code': 'IOT', 'label': 'Territorio Británico del Océano Índico (el)'},
{'code': 'IRL', 'label': 'Irlanda'},
{'code': 'IRN', 'label': 'Irán (la República Islámica de)'},
{'code': 'IRQ', 'label': 'Irak'},
{'code': 'ISL', 'label': 'Islandia'},
{'code': 'ISR', 'label': 'Israel'},
{'code': 'ITA', 'label': 'Italia'},
{'code': 'JAM', 'label': 'Jamaica'},
{'code': 'JEY', 'label': 'Jersey'},
{'code': 'JOR', 'label': 'Jordania'},
{'code': 'JPN', 'label': 'Japón'},
{'code': 'KAZ', 'label': 'Kazajistán'},
{'code': 'KEN', 'label': 'Kenia'},
{'code': 'KGZ', 'label': 'Kirguistán'},
{'code': 'KHM', 'label': 'Camboya'},
{'code': 'KIR', 'label': 'Kiribati'},
{'code': 'KNA', 'label': 'San Cristóbal y Nieves'},
{'code': 'KOR', 'label': 'Corea (la República de)'},
{'code': 'KWT', 'label': 'Kuwait'},
{'code': 'LAO', 'label': 'Lao, (la) República Democrática Popular'},
{'code': 'LBN', 'label': 'Líbano'},
{'code': 'LBR', 'label': 'Liberia'},
{'code': 'LBY', 'label': 'Libia'},
{'code': 'LCA', 'label': 'Santa Lucía'},
{'code': 'LIE', 'label': 'Liechtenstein'},
{'code': 'LKA', 'label': 'Sri Lanka'},
{'code': 'LSO', 'label': 'Lesoto'},
{'code': 'LTU', 'label': 'Lituania'},
{'code': 'LUX', 'label': 'Luxemburgo'},
{'code': 'LVA', 'label': 'Letonia'},
{'code': 'MAC', 'label': 'Macao'},
{'code': 'MAF', 'label': 'San Martín (parte francesa)'},
{'code': 'MAR', 'label': 'Marruecos'},
{'code': 'MCO', 'label': 'Mónaco'},
{'code': 'MDA', 'label': 'Moldavia (la República de)'},
{'code': 'MDG', 'label': 'Madagascar'},
{'code': 'MDV', 'label': 'Maldivas'},
{'code': 'MEX', 'label': 'México'},
{'code': 'MHL', 'label': 'Islas Marshall (las)'},
{'code': 'MKD', 'label': 'Macedonia (la antigua República Yugoslava de)'},
{'code': 'MLI', 'label': 'Malí'},
{'code': 'MLT', 'label': 'Malta'},
{'code': 'MMR', 'label': 'Myanmar'},
{'code': 'MNE', 'label': 'Montenegro'},
{'code': 'MNG', 'label': 'Mongolia'},
{'code': 'MNP', 'label': 'Islas Marianas del Norte (las)'},
{'code': 'MOZ', 'label': 'Mozambique'},
{'code': 'MRT', 'label': 'Mauritania'},
{'code': 'MSR', 'label': 'Montserrat'},
{'code': 'MTQ', 'label': 'Martinica'},
{'code': 'MUS', 'label': 'Mauricio'},
{'code': 'MWI', 'label': 'Malaui'},
{'code': 'MYS', 'label': 'Malasia'},
{'code': 'MYT', 'label': 'Mayotte'},
{'code': 'NAM', 'label': 'Namibia'},
{'code': 'NCL', 'label': 'Nueva Caledonia'},
{'code': 'NER', 'label': 'Níger (el)'},
{'code': 'NFK', 'label': 'Isla Norfolk'},
{'code': 'NGA', 'label': 'Nigeria'},
{'code': 'NIC', 'label': 'Nicaragua'},
{'code': 'NIU', 'label': 'Niue'},
{'code': 'NLD', 'label': 'Países Bajos (los)'},
{'code': 'NOR', 'label': 'Noruega'},
{'code': 'NPL', 'label': 'Nepal'},
{'code': 'NRU', 'label': 'Nauru'},
{'code': 'NZL', 'label': 'Nueva Zelanda'},
{'code': 'OMN', 'label': 'Omán'},
{'code': 'PAK', 'label': 'Pakistán'},
{'code': 'PAN', 'label': 'Panamá'},
{'code': 'PCN', 'label': 'Pitcairn'},
{'code': 'PER', 'label': 'Perú'},
{'code': 'PHL', 'label': 'Filipinas (las)'},
{'code': 'PLW', 'label': 'Palaos'},
{'code': 'PNG', 'label': 'Papúa Nueva Guinea'},
{'code': 'POL', 'label': 'Polonia'},
{'code': 'PRI', 'label': 'Puerto Rico'},
{'code': 'PRK', 'label': 'Corea (la República Democrática Popular de)'},
{'code': 'PRT', 'label': 'Portugal'},
{'code': 'PRY', 'label': 'Paraguay'},
{'code': 'PSE', 'label': 'Palestina, Estado de'},
{'code': 'PYF', 'label': 'Polinesia Francesa'},
{'code': 'QAT', 'label': 'Catar'},
{'code': 'REU', 'label': 'Reunión'},
{'code': 'ROU', 'label': 'Rumania'},
{'code': 'RUS', 'label': 'Rusia, (la) Federación de'},
{'code': 'RWA', 'label': 'Ruanda'},
{'code': 'SAU', 'label': 'Arabia Saudita'},
{'code': 'SDN', 'label': 'Sudán (el)'},
{'code': 'SEN', 'label': 'Senegal'},
{'code': 'SGP', 'label': 'Singapur'},
{'code': 'SGS', 'label': 'Georgia del sur y las islas sandwich del sur'},
{'code': 'SHN', 'label': 'Santa Helena, Ascensión y Tristán de Acuña'},
{'code': 'SJM', 'label': 'Svalbard y Jan Mayen'},
{'code': 'SLB', 'label': 'Islas Salomón (las)'},
{'code': 'SLE', 'label': 'Sierra leona'},
{'code': 'NULL', 'label': 'NULL'}]},
'estado': {'label': 'Estado / Provincia',
'is_system': True,
'items': [{'code': 'AGU', 'label': 'Aguascalientes', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'BCN', 'label': 'Baja California', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'BCS', 'label': 'Baja California Sur', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CAM', 'label': 'Campeche', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CHP', 'label': 'Chiapas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CHH', 'label': 'Chihuahua', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CMX', 'label': 'Ciudad de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'COA', 'label': 'Coahuila', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'COL', 'label': 'Colima', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'DUR', 'label': 'Durango', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'GUA', 'label': 'Guanajuato', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'GRO', 'label': 'Guerrero', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'HID', 'label': 'Hidalgo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'JAL', 'label': 'Jalisco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'MEX', 'label': 'Estado de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'MIC', 'label': 'Michoacán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'MOR', 'label': 'Morelos', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'NAY', 'label': 'Nayarit', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'NLE', 'label': 'Nuevo León', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'OAX', 'label': 'Oaxaca', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'PUE', 'label': 'Puebla', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'QUE', 'label': 'Querétaro', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'ROO', 'label': 'Quintana Roo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'SLP', 'label': 'San Luis Potosí', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'SIN', 'label': 'Sinaloa', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'SON', 'label': 'Sonora', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'TAB', 'label': 'Tabasco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'TAM', 'label': 'Tamaulipas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'TLA', 'label': 'Tlaxcala', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'VER', 'label': 'Veracruz', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'YUC', 'label': 'Yucatán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'ZAC', 'label': 'Zacatecas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}]},
'tipo_domicilio': {'label': 'Tipo de domicilio',
'is_system': False,
'items': [{'code': 'fiscal', 'label': 'Fiscal'},
{'code': 'oficina', 'label': 'Oficina'},
{'code': 'sucursal', 'label': 'Sucursal'},
{'code': 'bodega', 'label': 'Bodega'},
{'code': 'patio', 'label': 'Patio'},
{'code': 'terminal', 'label': 'Terminal'},
{'code': 'almacen', 'label': 'Almacén'}]},
'area': {'label': 'Área / Departamento',
'is_system': False,
'items': [{'code': 'ventas', 'label': 'Ventas'},
{'code': 'operaciones', 'label': 'Operaciones'},
{'code': 'facturacion', 'label': 'Facturación'},
{'code': 'cobranza', 'label': 'Cobranza'},
{'code': 'servicio_cliente', 'label': 'Servicio al cliente'}]},
'cobertura': {'label': 'Cobertura',
'is_system': True,
'items': [{'code': 'nacional', 'label': 'Nacional'},
{'code': 'internacional', 'label': 'Internacional'}]},
'clasificacion_proveedor': {'label': 'Clasificación del proveedor',
'is_system': False,
'items': [{'code': 'naviera', 'label': 'Naviera'},
{'code': 'aerolinea', 'label': 'Aerolínea'},
{'code': 'transportista_terrestre', 'label': 'Transportista Terrestre'},
{'code': 'ferrocarril', 'label': 'Ferrocarril'},
{'code': 'agente_aduanal', 'label': 'Agente Aduanal'},
{'code': 'agente_carga', 'label': 'Agente de Carga'},
{'code': 'agente_corresponsal', 'label': 'Agente Corresponsal'},
{'code': 'almacen', 'label': 'Almacén'},
{'code': 'aseguradora', 'label': 'Aseguradora'},
{'code': 'paqueteria', 'label': 'Paquetería'},
{'code': 'otro', 'label': 'Otro'}]},
'tipo_equipo': {'label': 'Tipo de equipo / contenedor',
'is_system': True,
'items': [{'code': '40DC',
'label': "40' Standard",
'extra': {'modo': 'maritimo',
'largo_m': 12.035,
'ancho_m': 2.35,
'alto_m': 2.392,
'capacidad_m3': 67.7,
'tara_kg': 3700,
'carga_max_kg': 26790}},
{'code': '20DC',
'label': "20' Standard",
'extra': {'modo': 'maritimo',
'largo_m': 5.9,
'ancho_m': 2.35,
'alto_m': 2.392,
'capacidad_m3': 33.2,
'tara_kg': 2230,
'carga_max_kg': 21770}},
{'code': '20OT',
'label': "20' Open Top",
'extra': {'modo': 'maritimo',
'largo_m': 5.894,
'ancho_m': 2.311,
'alto_m': 2.354,
'capacidad_m3': 32.23,
'tara_kg': 2400,
'carga_max_kg': 30490}},
{'code': '20FR',
'label': "20' Flat Rack",
'extra': {'modo': 'maritimo',
'largo_m': 5.62,
'ancho_m': 2.23,
'alto_m': 2.233,
'tara_kg': 2530,
'carga_max_kg': 21470}},
{'code': '40HC',
'label': "40' High Cube",
'extra': {'modo': 'maritimo',
'largo_m': 12.036,
'ancho_m': 2.35,
'alto_m': 2.697,
'capacidad_m3': 76.3,
'tara_kg': 3970,
'carga_max_kg': 26510}},
{'code': '20PL',
'label': "20' Platform",
'extra': {'modo': 'maritimo',
'largo_m': 6.058,
'ancho_m': 2.438,
'alto_m': 0.37,
'tara_kg': 2520,
'carga_max_kg': 27960}},
{'code': '20FRC',
'label': "20' Flat Rack Collapsible",
'extra': {'modo': 'maritimo',
'largo_m': 5.618,
'ancho_m': 2.206,
'alto_m': 2.233,
'tara_kg': 2750,
'carga_max_kg': 27730}},
{'code': '20BK',
'label': "20' Bulk",
'extra': {'modo': 'maritimo',
'largo_m': 5.93,
'ancho_m': 2.35,
'alto_m': 2.34,
'capacidad_m3': 32.0,
'tara_kg': 2450,
'carga_max_kg': 21350}},
{'code': '20TK',
'label': "20' Tank",
'extra': {'modo': 'maritimo',
'largo_m': 6.058,
'ancho_m': 2.438,
'alto_m': 2.438,
'tara_kg': 4100,
'carga_max_kg': 26200}},
{'code': 'LD2',
'label': 'LD2',
'extra': {'modo': 'aereo',
'capacidad_m3': 3.5,
'tara_kg': 30,
'carga_max_kg': 1225,
'nota': 'Aviones 767'}},
{'code': 'LD3',
'label': 'LD3',
'extra': {'modo': 'aereo',
'capacidad_m3': 4.2,
'tara_kg': 80,
'carga_max_kg': 1587,
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
{'code': 'LBD',
'label': 'LBD (Flex Door)',
'extra': {'modo': 'aereo',
'capacidad_m3': 7.0,
'tara_kg': 123,
'carga_max_kg': 2449,
'nota': 'Aviones 767'}},
{'code': 'LD6',
'label': 'LD6',
'extra': {'modo': 'aereo',
'capacidad_m3': 8.9,
'tara_kg': 175,
'carga_max_kg': 3175,
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
{'code': 'PAG',
'label': 'PAP / PIP / PAG',
'extra': {'modo': 'aereo',
'capacidad_m3': 10.0,
'tara_kg': 120,
'carga_max_kg': 6033,
'nota': 'Boeing 747/767/777/DC10'}},
{'code': 'LD9',
'label': 'LD9 AAP',
'extra': {'modo': 'aereo',
'capacidad_m3': 10.0,
'tara_kg': 85,
'carga_max_kg': 1588,
'nota': 'Boeing 747/777/DC10'}},
{'code': 'XAW',
'label': 'XAW',
'extra': {'modo': 'aereo',
'capacidad_m3': 14.0,
'tara_kg': 170,
'carga_max_kg': 5000,
'nota': 'Boeing 747/777/DC10'}},
{'code': 'PMC',
'label': 'PMC',
'extra': {'modo': 'aereo',
'capacidad_m3': 12.7,
'tara_kg': 130,
'carga_max_kg': 6804,
'nota': 'Boeing 747/767/777'}},
{'code': 'LD8',
'label': 'LD8',
'extra': {'modo': 'aereo', 'capacidad_m3': 7.2, 'tara_kg': 120, 'carga_max_kg': 2450}},
{'code': 'DV48',
'label': "Dry Van 48'",
'extra': {'modo': 'terrestre',
'largo_m': 14.63,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 98.0,
'carga_max_kg': 20412,
'pallets': 22}},
{'code': 'SD',
'label': 'Legal Step Deck (Single Drop)',
'extra': {'modo': 'terrestre',
'largo_m': 11.58,
'ancho_m': 2.59,
'alto_m': 3.05,
'carga_max_kg': 20865}},
{'code': 'TANK',
'label': 'Tanker',
'extra': {'modo': 'terrestre',
'largo_m': 16.15,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_l': 22712}},
{'code': 'DV53',
'label': "Dry Van 53'",
'extra': {'modo': 'terrestre',
'largo_m': 16.15,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 99.11,
'carga_max_kg': 20412,
'pallets': 26}},
{'code': 'DD',
'label': 'Double Drop (Low Boy)',
'extra': {'modo': 'terrestre',
'largo_m': 8.53,
'ancho_m': 2.59,
'alto_m': 3.51,
'carga_max_kg': 18144}},
{'code': 'RF48',
'label': "48' Reefer Trailer",
'extra': {'modo': 'terrestre',
'largo_m': 14.63,
'ancho_m': 2.4,
'alto_m': 2.3,
'capacidad_m3': 90.0,
'carga_max_kg': 19958,
'pallets': 20}},
{'code': 'FB48',
'label': "48' Legal Flatbed",
'extra': {'modo': 'terrestre',
'largo_m': 14.63,
'ancho_m': 2.59,
'alto_m': 2.59,
'carga_max_kg': 21772}},
{'code': 'PUP28',
'label': "Pup Trailer 28'",
'extra': {'modo': 'terrestre',
'largo_m': 8.53,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 57.45,
'carga_max_kg': 9979,
'pallets': 14}},
{'code': 'IM53',
'label': "Intermodal 53' Container",
'extra': {'modo': 'terrestre',
'largo_m': 16.15,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 99.11,
'carga_max_kg': 19958,
'pallets': 24}}]},
'modo_tarifario': {'label': 'Modo de tarifario',
'is_system': True,
'items': [{'code': 'aereo', 'label': 'Aéreo'},
{'code': 'maritimo_fcl', 'label': 'Marítimo FCL'},
{'code': 'maritimo_lcl', 'label': 'Marítimo LCL'},
{'code': 'terrestre', 'label': 'Terrestre'}]},
'unidad_tarifa': {'label': 'Unidad de tarifa',
'is_system': True,
'items': [{'code': 'per_kg', 'label': 'Por kg'},
{'code': 'per_wm', 'label': 'Por peso/medida (W/M)'},
{'code': 'per_container', 'label': 'Por contenedor'},
{'code': 'flat', 'label': 'Tarifa plana'}]},
'concepto_cargo': {'label': 'Concepto de cargo',
'is_system': False,
'items': [{'code': 'combustible', 'label': 'Combustible (BAF/FSC)'},
{'code': 'dgr', 'label': 'Mercancía peligrosa (DGR)'},
{'code': 'moc', 'label': 'MOC (mínimo origen)'},
{'code': 'afs', 'label': 'AFS'},
{'code': 'thc', 'label': 'THC (manejo en terminal)'},
{'code': 'maniobras', 'label': 'Maniobras'},
{'code': 'almacenaje', 'label': 'Almacenaje'},
{'code': 'seguro', 'label': 'Seguro'},
{'code': 'despacho', 'label': 'Despacho aduanal'},
{'code': 'documentacion', 'label': 'Documentación'},
{'code': 'custodia', 'label': 'Custodia'},
{'code': 'otro', 'label': 'Otro'}]}}
TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece',
'puerto': 'Puertos donde opera',
'aeropuerto': 'Aeropuertos donde opera',
'aduana': 'Aduanas donde opera'}
# ---------------------------------------------------------------------------
# Catálogos del proceso comercial (Solicitud de servicio → Cotización).
# Alimentan los selects de la solicitud y del ciclo Oportunidad→Cotización.
# is_system = catálogos base que el cliente no puede borrar (sólo activar/desactivar).
# ---------------------------------------------------------------------------
GLOBAL_CATALOGS.update({
'tipo_operacion': {'label': 'Tipo de operación',
'is_system': True,
'items': [{'code': 'importacion', 'label': 'Importación'},
{'code': 'exportacion', 'label': 'Exportación'}]},
'medio_transporte': {'label': 'Medio de transporte',
'is_system': True,
'items': [{'code': 'maritimo', 'label': 'Marítimo'},
{'code': 'aereo', 'label': 'Aéreo'},
{'code': 'terrestre', 'label': 'Terrestre'},
{'code': 'ferroviario', 'label': 'Ferroviario'},
{'code': 'multimodal', 'label': 'Multimodal'}]},
'tipo_servicio': {'label': 'Tipo de servicio',
'is_system': True,
'items': [{'code': 'puerto_puerto', 'label': 'Puerto a puerto'},
{'code': 'puerto_puerta', 'label': 'Puerto a puerta'},
{'code': 'puerta_puerto', 'label': 'Puerta a puerto'},
{'code': 'puerta_puerta', 'label': 'Puerta a puerta'}]},
'prioridad': {'label': 'Prioridad',
'is_system': False,
'items': [{'code': 'baja', 'label': 'Baja'},
{'code': 'normal', 'label': 'Normal'},
{'code': 'alta', 'label': 'Alta'},
{'code': 'urgente', 'label': 'Urgente'}]},
'tipo_mercancia': {'label': 'Tipo de mercancía',
'is_system': False,
'items': [{'code': 'general', 'label': 'Carga general'},
{'code': 'perecedera', 'label': 'Perecedera'},
{'code': 'peligrosa', 'label': 'Peligrosa (IMO)'},
{'code': 'refrigerada', 'label': 'Refrigerada'},
{'code': 'granel', 'label': 'Granel'},
{'code': 'sobredimensionada', 'label': 'Sobredimensionada'},
{'code': 'valiosa', 'label': 'Valiosa'},
{'code': 'otro', 'label': 'Otro'}]},
'unidad_medida': {'label': 'Unidad de medida',
'is_system': False,
'items': [{'code': 'cm', 'label': 'Centímetros (cm)'},
{'code': 'm', 'label': 'Metros (m)'},
{'code': 'in', 'label': 'Pulgadas (in)'},
{'code': 'ft', 'label': 'Pies (ft)'},
{'code': 'kg', 'label': 'Kilogramos (kg)'},
{'code': 'lb', 'label': 'Libras (lb)'},
{'code': 'm3', 'label': 'Metros cúbicos (m³)'}]},
'tipo_embalaje': {'label': 'Tipo de embalaje',
'is_system': False,
'items': [{'code': 'caja', 'label': 'Caja'},
{'code': 'pallet', 'label': 'Pallet'},
{'code': 'tarima', 'label': 'Tarima'},
{'code': 'huacal', 'label': 'Huacal'},
{'code': 'saco', 'label': 'Saco'},
{'code': 'tambor', 'label': 'Tambor'},
{'code': 'rollo', 'label': 'Rollo'},
{'code': 'atado', 'label': 'Atado'},
{'code': 'granel', 'label': 'Granel'},
{'code': 'otro', 'label': 'Otro'}]},
'servicio_adicional': {'label': 'Servicios adicionales',
'is_system': False,
'items': [{'code': 'seguro', 'label': 'Seguro de la mercancía'},
{'code': 'despacho_aduanal', 'label': 'Despacho aduanal'},
{'code': 'transporte_terrestre', 'label': 'Transporte terrestre'},
{'code': 'almacenaje', 'label': 'Almacenaje'},
{'code': 'maniobras', 'label': 'Maniobras'},
{'code': 'custodia', 'label': 'Custodia'},
{'code': 'revalidacion', 'label': 'Revalidación'},
{'code': 'inspeccion', 'label': 'Inspección'},
{'code': 'otro', 'label': 'Otro'}]},
'tipo_documento': {'label': 'Tipo de documento',
'is_system': False,
'items': [{'code': 'factura_comercial', 'label': 'Factura comercial'},
{'code': 'packing_list', 'label': 'Packing list'},
{'code': 'certificado_origen', 'label': 'Certificado de origen'},
{'code': 'hoja_seguridad_msds', 'label': 'Hoja de seguridad (MSDS)'},
{'code': 'ficha_tecnica', 'label': 'Ficha técnica'},
{'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'},
{'code': 'otro', 'label': 'Otro'}]},
'incoterm': {'label': 'Incoterm (2020)',
'is_system': True,
'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'},
{'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'},
{'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'},
{'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'},
{'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'},
{'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'},
{'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'},
{'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'},
{'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'},
{'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'},
{'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]},
})
# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08).
# El SAT exige dos posiciones; se corrige el catálogo base.
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
if len(_fp['code']) == 1:
_fp['code'] = _fp['code'].zfill(2)
# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`.
from .seed_locations import LOCATION_CATALOGS # noqa: E402
GLOBAL_CATALOGS.update(LOCATION_CATALOGS)

View File

@@ -0,0 +1,79 @@
"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA).
Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=<ISO3>`). Curado a las
rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios).
Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario
para que el Cotizador encuentre ruta.
"""
# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)])
_LOC = [
("MEX",
[("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"),
("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")],
[("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"),
("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")],
[("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"),
("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]),
("USA",
[("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"),
("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")],
[("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"),
("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")],
[("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"),
("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]),
("CHN",
[("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"),
("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")],
[("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"),
("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")],
[("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"),
("SZX", "Shenzhen"), ("HKG", "Hong Kong")]),
("DEU",
[("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")],
[("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")],
[("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]),
("ESP",
[("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")],
[("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")],
[("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]),
("NLD",
[("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")],
[("NLRTM", "Róterdam")],
[("AMS", "Ámsterdam Schiphol")]),
("BRA",
[("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")],
[("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")],
[("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]),
("CAN",
[("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")],
[("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")],
[("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]),
("JPN",
[("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")],
[("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")],
[("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]),
("KOR",
[("KR-SEL", "Seúl"), ("KR-PUS", "Busan")],
[("KRPUS", "Busan"), ("KRINC", "Incheon")],
[("ICN", "Seúl Incheon")]),
]
def _build() -> dict:
ciudad, puerto, aeropuerto = [], [], []
for iso3, cities, ports, airports in _LOC:
for code, label in cities:
ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3})
for code, label in ports:
puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
for code, label in airports:
aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
return {
"ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad},
"puerto": {"label": "Puerto", "is_system": True, "items": puerto},
"aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto},
}
LOCATION_CATALOGS = _build()

View File

@@ -0,0 +1,168 @@
"""Lógica de negocio de los catálogos de referencia del CRM."""
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from core.security import is_hub_admin
from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta
from .models import CatalogItem
from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS
# Metadata de catálogos (labels y si el cliente puede llenarlos).
CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()}
CATALOG_LABELS.update(TENANT_CATALOG_LABELS)
# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft).
TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys())
KNOWN_CATALOGS = set(CATALOG_LABELS.keys())
def _require_known(catalog: str) -> None:
if catalog not in KNOWN_CATALOGS:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe")
def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]:
"""Lista todos los catálogos disponibles con su conteo (global + del tenant)."""
out: list[CatalogMeta] = []
for key, label in CATALOG_LABELS.items():
is_tenant = key in TENANT_CATALOG_KEYS
count = (
db.query(CatalogItem)
.filter(
CatalogItem.catalog == key,
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
)
.count()
)
out.append(
CatalogMeta(
catalog=key,
label=label,
scope="tenant" if is_tenant else "global",
is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)),
count=count,
)
)
return out
def list_items(
db: Session,
catalog: str,
tenant_id: int,
parent_code: str | None = None,
include_inactive: bool = False,
) -> list[CatalogItem]:
_require_known(catalog)
q = db.query(CatalogItem).filter(
CatalogItem.catalog == catalog,
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
)
if not include_inactive:
q = q.filter(CatalogItem.is_active.is_(True))
if parent_code:
q = q.filter(CatalogItem.parent_code == parent_code)
return q.order_by(CatalogItem.sort_order, CatalogItem.label).all()
def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None:
"""Devuelve el tenant_id a usar al escribir (None = global) y valida permisos.
- scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft).
- scope 'tenant' (default) → se guarda en el tenant del usuario.
"""
wants_global = scope == "global"
if wants_global:
if not is_hub_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo un administrador de Aduanasoft puede editar catálogos globales.",
)
return None
return int(current_user["tenant_id"])
def create_item(
db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None
) -> CatalogItem:
_require_known(catalog)
target_tenant = _resolve_write_scope(catalog, scope, current_user)
# No duplicar por (catalog, code, tenant_id)
exists = (
db.query(CatalogItem)
.filter(
CatalogItem.catalog == catalog,
CatalogItem.code == data.code,
CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant,
)
.first()
)
if exists:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.",
)
item = CatalogItem(
catalog=catalog,
code=data.code,
label=data.label,
parent_catalog=data.parent_catalog,
parent_code=data.parent_code,
tenant_id=target_tenant,
sort_order=data.sort_order,
is_active=data.is_active,
is_system=False,
created_by=current_user.get("sub"),
updated_by=current_user.get("sub"),
)
db.add(item)
db.commit()
db.refresh(item)
return item
def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem:
_require_known(catalog)
item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
if item.tenant_id is None:
# Global (Aduanasoft): solo hub_admin.
if not is_hub_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo un administrador de Aduanasoft puede editar este catálogo global.",
)
elif item.tenant_id != int(current_user["tenant_id"]):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
return item
def update_item(
db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict
) -> CatalogItem:
item = _get_writable(db, catalog, item_id, current_user)
payload: dict[str, Any] = data.model_dump(exclude_unset=True)
for field, value in payload.items():
setattr(item, field, value)
item.updated_by = current_user.get("sub")
db.commit()
db.refresh(item)
return item
def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None:
item = _get_writable(db, catalog, item_id, current_user)
if item.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.",
)
db.delete(item)
db.commit()

View File

@@ -0,0 +1,96 @@
"""Folios auto-generados del ciclo comercial (Oportunidad → Solicitud → Cotización → Operación).
Formato: ``{LETRA}{AAAA}-{MM}-{NNN}-{DIR}`` (ej. ``O2025-08-001-E``):
- LETRA: entidad — ``O`` Oportunidad, ``S`` Solicitud, ``C`` Cotización, ``OP`` Operación/Embarque.
- ``AAAA-MM``: año-mes de creación.
- ``NNN``: consecutivo **mensual** por compañía y por entidad (reinicia cada mes).
- ``DIR``: ``I`` importación / ``E`` exportación (``X`` si aún no se define la dirección).
El consecutivo se toma de ``crm.folio_counters`` con bloqueo de fila para evitar
duplicados por concurrencia. En SQLite (pruebas) el ``FOR UPDATE`` se ignora sin error;
la unicidad la garantiza el índice único (tenant, company, entity, period).
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import Integer, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin
from core.database import Base
# Entidades válidas y su letra de folio (F = factura, EXP = expediente; sin dirección).
ENTITIES = ("O", "S", "C", "OP", "F", "EXP")
# Mapa dirección de operación → sufijo del folio.
_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"}
class FolioCounter(Base, TenantScopedMixin, BaseTimestampMixin):
"""Consecutivo mensual por compañía y entidad para armar los folios del ciclo."""
__tablename__ = "folio_counters"
__table_args__ = (
UniqueConstraint(
"tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope"
),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
entity: Mapped[str] = mapped_column(String(4), nullable=False) # O | S | C | OP
period: Mapped[str] = mapped_column(String(7), nullable=False) # 'AAAA-MM'
last_number: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
def direction_suffix(direction: str | None) -> str:
"""Devuelve la letra de dirección del folio (I/E) o 'X' si no está definida."""
return _DIRECTION_SUFFIX.get(direction or "", "X")
def next_folio(
db,
tenant_id: int,
company_id: int,
entity: str,
direction: str | None,
on_date: date | None = None,
with_direction: bool = True,
) -> str:
"""Genera el siguiente folio de una entidad, incrementando su consecutivo mensual.
Reserva el número dentro de la transacción activa (no hace commit): el ``create_*``
que lo invoca es quien confirma junto con la fila recién creada. ``with_direction=False``
omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``).
"""
if entity not in ENTITIES:
raise ValueError(f"Entidad de folio inválida: {entity!r}")
on_date = on_date or date.today()
period = on_date.strftime("%Y-%m")
counter = (
db.query(FolioCounter)
.filter(
FolioCounter.tenant_id == tenant_id,
FolioCounter.company_id == company_id,
FolioCounter.entity == entity,
FolioCounter.period == period,
)
.with_for_update()
.first()
)
if counter is None:
counter = FolioCounter(
tenant_id=tenant_id, company_id=company_id, entity=entity, period=period, last_number=0
)
db.add(counter)
db.flush()
counter.last_number = (counter.last_number or 0) + 1
db.flush()
sequence = f"{counter.last_number:03d}"
if not with_direction:
return f"{entity}{period}-{sequence}"
return f"{entity}{period}-{sequence}-{direction_suffix(direction)}"

View File

@@ -0,0 +1,37 @@
"""Cálculos de precio compartidos del proceso comercial.
Peso volumétrico / a cobrar de carga aérea (doc maestro de cotización):
P/Vol = (Largo_cm × Ancho_cm × Alto_cm × cantidad) / 6000
El peso a cobrar es el mayor entre el peso bruto y el P/Vol (estándar aéreo).
6000 cm³/kg es el factor internacional (equivale a ~167 kg/m³).
"""
from __future__ import annotations
from decimal import Decimal
# Factor internacional de peso volumétrico aéreo (cm³ por kg).
AIR_VOLUMETRIC_DIVISOR = Decimal("6000")
def _d(value) -> Decimal:
if value is None:
return Decimal(0)
return value if isinstance(value, Decimal) else Decimal(str(value))
def air_volumetric_kg(length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso volumétrico aéreo a partir de dimensiones (cm) y cantidad de bultos.
Devuelve 0 si falta alguna dimensión (no se puede calcular).
"""
length, width, height = _d(length_cm), _d(width_cm), _d(height_cm)
if length <= 0 or width <= 0 or height <= 0:
return Decimal(0)
quantity = _d(qty) if _d(qty) > 0 else Decimal(1)
return (length * width * height * quantity) / AIR_VOLUMETRIC_DIVISOR
def air_chargeable_kg(gross_kg, length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso a cobrar aéreo: max(peso bruto, peso volumétrico por dimensiones)."""
return max(_d(gross_kg), air_volumetric_kg(length_cm, width_cm, height_cm, qty))

View File

@@ -1,20 +1,15 @@
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Document(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
class Document(Base, TenantScopedMixin, TimestampMixin):
"""Documento de un cliente (``account_id``) o proveedor (``supplier_id``).
Guarda los metadatos y una referencia al archivo (``file_key`` en MinIO/S3 o
``file_url`` externa). La subida binaria se hace vía la capa de storage.
Con ``EfcDocumentRefMixin`` la fila además refleja el estado del documento en el expediente
electrónico de EFC. Un documento del CRM puede vivir en tres modos y la descarga se ramifica por
ellos: ``efc_document_id`` (está en EFC → proxy), ``file_key`` (solo local → URL firmada) o
``file_url`` (externa).
"""
__tablename__ = "documents"
@@ -27,6 +22,10 @@ class Document(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# Documento adjunto a una solicitud de servicio (factura, packing list, MSDS, etc.)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
# constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio |
# contrato | presentacion | certificacion | licencia | convenio | tarifario | otro
doc_type: Mapped[str] = mapped_column(String(60), nullable=False)

View File

@@ -1,4 +1,3 @@
import logging
from datetime import datetime, timezone
from fastapi import HTTPException, status
@@ -9,8 +8,6 @@ from ..suppliers.models import Supplier
from .dto import DocumentCreate, DocumentUpdate
from .models import Document
logger = logging.getLogger(__name__)
def _validate_owner(db: Session, account_id: int | None, supplier_id: int | None, tenant_id: int, company_id: int) -> None:
"""Un documento debe pertenecer a exactamente un cliente o proveedor existente."""
@@ -98,30 +95,6 @@ def update_document(
def delete_document(db: Session, document_id: int, tenant_id: int, company_id: int) -> None:
"""Baja lógica del documento. Si estaba en un expediente, lo **desasocia**; no lo destruye.
El CRM no llama al DELETE de EFC, y es deliberado: el gateway de Anexo22 tampoco lo hace
—verificado, ese método no existe en su cliente— y ``record.Document`` en EFC no tiene vigencia
ni purga, así que la política implícita del sistema es conservar. Un documento que mañana puede
ser parte del expediente de un pedimento real es riesgo de retención fiscal.
El objeto local sí se limpia cuando todavía existe: si ya se entregó a EFC, ``delete_local`` lo
borró al confirmar y ``file_key`` está en ``None``.
"""
document = get_document(db, document_id, tenant_id, company_id)
document.deleted_at = datetime.now(timezone.utc)
document.expediente_id = None
if document.file_key:
try:
from core.storage_s3 import delete_object_if_exists
delete_object_if_exists(document.file_key)
except Exception:
# El borrado del objeto es una consecuencia de la baja, no parte de ella: dejar un
# objeto huérfano es preferible a no poder dar de baja el documento.
logger.warning(
"documents: no se pudo borrar el objeto local %s del documento %s",
document.file_key, document.id, exc_info=True,
)
document.file_key = None
db.commit()

View File

@@ -1,131 +0,0 @@
"""Outbox transaccional del carril CRM Agentes de Carga -> EFC.
DOS tablas separadas POR PROPÓSITO, igual que en el carril de referencia de Anexo22: una para los
expedientes (metadatos, JSON) y otra para los archivos (binarios que viven en MinIO y se referencian
por su ``s3_key``). Un worker de Celery las drena hacia EFC con reintentos.
**Diferencia con el original, y es necesaria:** aquí las filas se insertan en la MISMA transacción
que el expediente o el documento, porque el CRM es mono-base. En Anexo22 el outbox vivía en otra
base que el pedimento, y ese doble-commit es justamente lo que obligó a inventar el barrido de
huecos. Aquí el barrido se conserva —cubre lo creado antes de activar la integración y cualquier
crash— pero deja de ser el parche de una ventana estructural.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
# Tipo de trabajo (columna kind) del outbox de EXPEDIENTES.
KIND_EXPEDIENTE = "expediente"
KIND_COMPLETAR = "completar"
# Tipos del outbox de ARCHIVOS (efc_file_outbox).
FILE_KIND_DOCUMENTO = "documento"
# Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias
# independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y
# ops.shipment_documents.id = 5 coexisten.
SOURCE_CRM_DOCUMENTS = "crm.documents"
SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents"
SOURCE_FIN_INVOICES = "fin.invoices"
# Estados (columna status).
STATUS_PENDING = "pending"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
# Tope de reintentos antes de marcar 'failed' (reconciliación / reintento manual).
# Heredado del carril de Anexo22. Con barridos de 120 s son ~17 minutos de insistencia antes de
# rendirse y dejar la fila visible para que una persona la reintente a mano.
MAX_ATTEMPTS = 8
class EfcSyncOutbox(Base, TenantScopedMixin, TimestampMixin):
"""Cola de metadatos hacia EFC: crear el expediente provisional y completarlo."""
__tablename__ = "efc_sync_outbox"
__table_args__ = (
Index("ix_crm_efc_sync_outbox_status", "status"),
Index("ix_crm_efc_sync_outbox_kind_status", "kind", "status"),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(20), nullable=False)
# Datos para construir el request a EFC (folio, storage_token, tenant slug, company, y la data
# aduanera si el kind es 'completar').
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
# id local del expediente (crm.expedientes.id) que originó la fila.
expediente_ref: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
# Ciclo de vida.
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Acuse de EFC al confirmar (trazabilidad).
efc_pedimento_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
class EfcFileOutbox(Base, TenantScopedMixin, TimestampMixin):
"""Cola de ARCHIVOS hacia EFC.
El binario vive en el MinIO del CRM (durable); esta fila referencia su ``s3_key`` y el expediente
destino. El worker lo sube a EFC y, con ``delete_local`` (corte directo), BORRA la copia local al
confirmar la entrega.
``delete_local`` **es el mecanismo de «EFC es la fuente única»**: "solo EFC" es el estado FINAL
(eventual), no el inmediato. Entre que el usuario sube el archivo y que EFC lo confirma, la copia
local es lo único que hay, y borrarla antes perdería el archivo si la entrega fallara.
``source_table`` es un añadido necesario sobre el original de Anexo22, que solo llevaba
``source_id``. El CRM tiene dos tablas de documentos con secuencias independientes, así que un
entero solo es ambiguo entre ellas. Es el mismo problema que Anexo22 resolvió con su mapa por
``kind``, y su comentario dice 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.
"""
__tablename__ = "efc_file_outbox"
__table_args__ = (
Index("ix_crm_efc_file_outbox_status", "status"),
Index("ix_crm_efc_file_outbox_kind_status", "kind", "status"),
Index("ix_crm_efc_file_outbox_source", "source_table", "source_id"),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(30), nullable=False)
# Objeto en MinIO a subir + metadata para el upload a EFC.
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
efc_tipo: Mapped[str] = mapped_column(String(40), nullable=False) # tipo de documento en EFC
# Origen: la pareja (tabla, id) desambigua entre las dos secuencias de documentos del CRM.
source_table: Mapped[str] = mapped_column(String(30), nullable=False)
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# El handle autoritativo que viaja a EFC y garantiza la idempotencia del lado de allá.
crm_document_ref: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
expediente_ref: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.expedientes.id"), nullable=False, index=True
)
delete_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
# Ciclo de vida.
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
efc_document_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)

View File

@@ -1,58 +0,0 @@
"""Endpoints de operación y observabilidad del carril CRM -> EFC.
Tablero mínimo para ver y reintentar la entrega de expedientes y documentos a EFC. Autenticado con
el auth normal del CRM y acotado por tenant/company, como el resto del módulo.
Montado bajo ``/v1/crm`` → ``/v1/crm/expediente-gateway/...``
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
router = APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"])
@router.get("/outbox")
def list_outbox(
company_id: int = Query(..., description="Company ID"),
tipo: str | None = Query(None, description="Filtrar por tabla: sync|file"),
status: str | None = Query(None, description="Filtrar por status: pending|sent|failed"),
limit: int = Query(100, ge=1, le=500),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Filas de los dos outbox, para ver los fallos y su ``last_error``."""
return service.list_outbox(db, current_user["tenant_id"], company_id, tipo, status, limit)
@router.post("/outbox/{outbox_id}/retry")
def retry_outbox(
outbox_id: int,
company_id: int = Query(..., description="Company ID"),
tipo: str = Query("file", description="Tabla de la fila: sync|file"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Reintento manual de una fila: la resetea a ``pending`` y la re-despacha.
Una fila inexistente devuelve **404 con mensaje específico**, no un 200 silencioso: el frontend
pinta el botón de reintento según lo que reciba, y un 200 le haría creer que la entrega volvió a
la cola cuando no hay nada que entregar.
"""
ok = service.retry_outbox_row(db, outbox_id, current_user["tenant_id"], company_id, tipo)
if not ok:
raise HTTPException(status_code=404, detail="Fila de outbox no encontrada")
return {"status": "requeued", "id": outbox_id}
@router.get("/metrics")
def metrics(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Conteo de los dos outbox por status (pending/sent/failed) para monitoreo."""
return service.outbox_metrics(db, current_user["tenant_id"], company_id)

View File

@@ -1,731 +0,0 @@
"""Carril CRM Agentes de Carga -> EFC: encolado, entrega y reconciliación.
Clon del gateway de Anexo22 (``anexo22/.../pedimentos/pedimento_gateway/service.py``), que es el
carril de referencia ya en producción. Quien conozca uno debe poder leer el otro, así que la tabla
de equivalencias va aquí:
====================================== ======================================
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**, con 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``
====================================== ======================================
**La máquina de reintentos tiene tres capas y las tres se conservan:**
1. En el cliente HTTP: 3 intentos, backoff lineal ``0.15 * (attempt + 1)``, corte seco en 4xx.
2. En el worker: ``deliver_row`` **nunca lanza**; registra el fallo en la propia fila.
3. En el beat: barridos cada 120 s que re-despachan lo ``pending``.
No hay ``autoretry_for``, ``retry_backoff`` ni ``max_retries`` en las tareas: duplicarían el
mecanismo que ya está en el cliente y en el barrido.
**Cuatro guardas de idempotencia**, en este orden:
1. ``_ya_entregado(source_table, source_id, kind)`` antes de encolar.
2. ``if row.status == STATUS_SENT: return`` al entrar a entregar.
3. El ``crm_document_ref`` que viaja con la subida: EFC devuelve 200 con el que ya existía.
4. El UNIQUE parcial del lado de EFC — la única que garantiza la base.
**Por qué ``find_expediente_gaps`` sigue aquí aunque el CRM sea mono-base.** En Anexo22 el outbox se
commitea aparte del pedimento (dos bases distintas) y ese doble-commit es lo que obligó a inventar el
barrido de huecos. Aquí la fila del outbox va en la MISMA transacción que el expediente, así que esa
ventana no existe. El barrido se conserva porque cubre otras dos cosas: los expedientes creados
**antes** de activar la integración, y cualquier crash. Queda escrito para que el siguiente que lo
lea no lo borre creyendo que es redundante.
"""
import logging
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from core.config import settings
from core.database import scoped_core_db
from core.efc_client import EfcClient, EfcClientError, efc_client
from ..expedientes.models import Expediente
from .models import (
KIND_COMPLETAR,
KIND_EXPEDIENTE,
MAX_ATTEMPTS,
STATUS_FAILED,
STATUS_PENDING,
STATUS_SENT,
EfcFileOutbox,
EfcSyncOutbox,
)
logger = logging.getLogger(__name__)
# Cache de organización EFC por slug de tenant. Dict módulo-global: vive por worker y NO se
# invalida, igual que el del carril de Anexo22. Es correcto porque la organización de un tenant no
# cambia de id: el resolver de EFC es idempotente y devuelve siempre la misma. Si algún día pudiera
# cambiar, reiniciar el worker la vuelve a resolver.
_org_id_cache: dict[str, str] = {}
@contextmanager
def _savepoint(db: Session):
"""Aísla un encolado dentro de la transacción del usuario con un SAVEPOINT.
**Esto es lo único del encolado que NO se clona del carril de Anexo22, y la razón es de fondo.**
Allá el outbox vive en otra base que el pedimento, así que su ``except`` podía hacer
``db.rollback()`` sin consecuencias: revertía la sesión del outbox y la del pedimento ni se
enteraba.
Aquí el CRM es mono-base y el encolado corre DENTRO de la transacción del usuario. Un
``db.rollback()`` en el ``except`` se llevaría por delante la solicitud y el expediente que el
usuario acaba de crear — exactamente lo contrario de best-effort, y sin un solo error visible
para él. Con el SAVEPOINT, un fallo del encolado deshace **solo** la fila del outbox y la
operación local sigue en pie para que el llamador la commitee.
"""
nested = db.begin_nested()
try:
yield nested
except Exception:
nested.rollback()
raise
# ══ Expediente: encolado y entrega ══════════════════════════════════════════
def replicate_expediente_best_effort(db: Session, expediente: Expediente) -> None:
"""Encola la réplica del expediente a EFC y dispara la entrega inmediata.
Best-effort en todo: si EFC no está configurado, o si el encolado o el despacho fallan, **no se
propaga el error**. El expediente local ya existe y la operación del usuario no se puede romper
porque un sistema de terceros no conteste. El barrido periódico recoge lo que quede pendiente.
"""
if not settings.EFC_API_URL:
return
row = _enqueue_expediente_outbox(db, expediente)
if row is None:
return
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
def _enqueue_expediente_outbox(db: Session, expediente: Expediente) -> Optional[EfcSyncOutbox]:
"""Inserta la fila de outbox del expediente. Devuelve ``None`` si falla, sin romper nada.
A diferencia del original, **no commitea**: el CRM es mono-base, así que la fila viaja en la
misma transacción que el expediente. Eso cierra de raíz la ventana del doble-commit que en
Anexo22 obligó a inventar el barrido de huecos.
"""
try:
if _expediente_ya_encolado(db, expediente.id):
return None
# El slug del tenant NO se resuelve aquí: se rellena al ENTREGAR. Resolverlo ahora abriría
# una segunda sesión de base (``scoped_core_db``) dentro de la transacción del usuario, que
# es justo lo que el encolado debe evitar. Es además lo que hace el carril de referencia.
payload = {
"source": "crm",
"crm_company_id": expediente.company_id,
"crm_expediente_id": expediente.id,
"folio": expediente.folio,
"storage_token": expediente.efc_storage_token,
}
row = EfcSyncOutbox(
kind=KIND_EXPEDIENTE,
payload=payload,
expediente_ref=expediente.id,
status=STATUS_PENDING,
tenant_id=expediente.tenant_id,
company_id=expediente.company_id,
)
with _savepoint(db):
db.add(row)
db.flush()
return row
except Exception:
logger.warning(
"expediente_gateway: no se pudo encolar el expediente id=%s en el outbox",
getattr(expediente, "id", None), exc_info=True,
)
return None
def _expediente_ya_encolado(db: Session, expediente_id: int) -> bool:
"""¿Ya hay una fila viva de alta para este expediente? Evita encolar la misma réplica dos veces."""
return (
db.query(EfcSyncOutbox.id)
.filter(
EfcSyncOutbox.expediente_ref == expediente_id,
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
EfcSyncOutbox.status.in_((STATUS_PENDING, STATUS_SENT)),
)
.first()
is not None
)
def enqueue_completar_best_effort(db: Session, expediente: Expediente, campos: dict) -> None:
"""Encola el completado del provisional en EFC con la data aduanera real."""
if not settings.EFC_API_URL:
return
try:
row = EfcSyncOutbox(
kind=KIND_COMPLETAR,
payload={
"source": "crm",
"crm_company_id": expediente.company_id,
"crm_expediente_id": expediente.id,
"folio": expediente.folio,
"pedimento": campos,
},
expediente_ref=expediente.id,
status=STATUS_PENDING,
tenant_id=expediente.tenant_id,
company_id=expediente.company_id,
)
with _savepoint(db):
db.add(row)
db.flush()
except Exception:
logger.warning(
"expediente_gateway: no se pudo encolar el completado del expediente id=%s",
getattr(expediente, "id", None), exc_info=True,
)
return
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
def _dispatch_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
"""Dispara la tarea de entrega propagando el contexto RLS por headers de Celery.
Best-effort: si el broker no responde, el barrido la recoge. Los headers son obligatorios —
``core/celery_app.py`` materializa el contexto de RLS a partir de ellos, y sin ellos la tarea
corre sin tenant y no ve nada.
"""
try:
from .tasks import deliver_outbox_row # import diferido: evita ciclo con celery_app
deliver_outbox_row.apply_async(
args=[outbox_id, tenant_id, company_id],
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
)
except Exception:
logger.warning(
"expediente_gateway: no se pudo despachar la entrega outbox_id=%s (lo tomará el sweep)",
outbox_id, exc_info=True,
)
def deliver_row(db: Session, row: EfcSyncOutbox, client: Optional[EfcClient] = None) -> None:
"""Entrega una fila del outbox de expedientes a EFC. Actualiza estado y ``attempts``.
**No lanza nunca**: los fallos se registran en la propia fila para reconciliación. Un fallo no
puede matar al worker ni perder la intención de entregar.
"""
client = client or efc_client
if not client.is_configured:
logger.info("expediente_gateway: EFC no configurado; se deja pendiente row=%s", row.id)
return
if row.status == STATUS_SENT:
return
try:
if row.kind == KIND_EXPEDIENTE:
_deliver_expediente(db, row, client)
elif row.kind == KIND_COMPLETAR:
_deliver_completar(db, row, client)
else:
row.status = STATUS_FAILED
row.last_error = f"kind desconocido: {row.kind}"
db.commit()
except EfcClientError as exc:
_register_failure(db, row, exc, retryable=exc.retryable)
except Exception as exc: # noqa: BLE001 — cualquier fallo se registra, no rompe el worker
_register_failure(db, row, exc, retryable=True)
def _register_failure(db: Session, row: EfcSyncOutbox, exc: Exception, retryable: bool) -> None:
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()
logger.warning(
"expediente_gateway: entrega falló row=%s attempts=%s retryable=%s status=%s: %s",
row.id, row.attempts, retryable, row.status, exc,
)
def _deliver_expediente(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
payload = dict(row.payload or {})
org_id = _resolve_org_id(client, row.tenant_id)
payload["organizacion"] = {"efc_organizacion_id": org_id}
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
resp = client.ingest_expediente(payload)
efc = (resp or {}).get("efc") or {}
row.status = STATUS_SENT
row.sent_at = datetime.now(timezone.utc)
row.efc_pedimento_id = efc.get("pedimento_id")
_stamp_expediente_link(db, row.expediente_ref, org_id, efc.get("pedimento_id"))
db.commit()
logger.info(
"expediente_gateway: expediente replicado row=%s efc_pedimento_id=%s",
row.id, row.efc_pedimento_id,
)
def _deliver_completar(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
payload = dict(row.payload or {})
org_id = _resolve_org_id(client, row.tenant_id)
payload["organizacion"] = {"efc_organizacion_id": org_id}
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
folio = payload.get("folio")
client.completar_expediente(folio, payload)
row.status = STATUS_SENT
row.sent_at = datetime.now(timezone.utc)
db.commit()
logger.info("expediente_gateway: expediente completado en EFC row=%s folio=%s", row.id, folio)
def _stamp_expediente_link(db: Session, expediente_id: Optional[int], org_id: str,
pedimento_id: Optional[str]) -> None:
"""Refleja en la fila del expediente que EFC ya lo tiene, para que la UI lo pinte.
Es un espejo, no un handle: el CRM sigue hablando de este expediente por su ``folio``. Se guarda
porque el proxy de descarga necesita el ``organizacion_id`` para preguntarle a EFC.
"""
if expediente_id is None:
return
expediente = db.query(Expediente).filter(Expediente.id == expediente_id).first()
if expediente is None:
return
expediente.efc_organizacion_id = org_id
if pedimento_id:
expediente.efc_pedimento_id = pedimento_id
expediente.efc_link_state = "LINKED"
expediente.efc_error_code = None
expediente.efc_error_detail = None
# ══ Archivos: encolado y entrega ════════════════════════════════════════════
def _ya_entregado(db: Session, source_table: str, source_id: int, kind: str) -> bool:
"""¿Este archivo ya se entregó al expediente? Evita re-encolar lo que ya está allá.
Sin esta guarda, un reintento encolaba otra entrega del mismo archivo — que además **falla al
leer el objeto local, porque la primera entrega ya lo borró** con ``delete_local``. Ruido en el
log y una fila del outbox condenada a ``failed``.
Lleva ``source_table`` además de ``source_id``, a diferencia del original: el CRM tiene dos
tablas de documentos con secuencias independientes, así que el id solo es ambiguo y esta guarda
se dispararía de más, saltándose la entrega de un documento distinto que casualmente comparte
entero.
"""
return (
db.query(EfcFileOutbox.id)
.filter(
EfcFileOutbox.source_table == source_table,
EfcFileOutbox.source_id == source_id,
EfcFileOutbox.kind == kind,
EfcFileOutbox.status == STATUS_SENT,
)
.first()
is not None
)
def enqueue_file_best_effort(
db: Session,
*,
kind: str,
s3_key: str,
file_name: str,
content_type: Optional[str],
efc_tipo: str,
source_table: str,
source_id: int,
crm_document_ref: str,
expediente_ref: int,
tenant_id: int,
company_id: int,
delete_local: bool = True,
) -> Optional[EfcFileOutbox]:
"""Encola un archivo hacia el expediente de EFC. Devuelve la fila, o ``None`` si no se encoló.
**No commitea**: la fila va en la misma transacción que el documento que la origina, de modo que
no puede existir un documento sin su intención de entrega ni al revés.
"""
if not settings.EFC_API_URL:
return None
if _ya_entregado(db, source_table, source_id, kind):
return None
try:
row = EfcFileOutbox(
kind=kind,
s3_key=s3_key,
file_name=file_name,
content_type=content_type,
efc_tipo=efc_tipo,
source_table=source_table,
source_id=source_id,
crm_document_ref=crm_document_ref,
expediente_ref=expediente_ref,
delete_local=delete_local,
status=STATUS_PENDING,
tenant_id=tenant_id,
company_id=company_id,
)
with _savepoint(db):
db.add(row)
db.flush()
return row
except Exception:
logger.warning(
"expediente_gateway: no se pudo encolar el archivo %s (%s:%s)",
s3_key, source_table, source_id, exc_info=True,
)
return None
def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
try:
from .tasks import deliver_file_outbox_row # import diferido
deliver_file_outbox_row.apply_async(
args=[outbox_id, tenant_id, company_id],
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
)
except Exception:
logger.warning(
"expediente_gateway: no se pudo despachar entrega de archivo outbox_id=%s (lo tomará el sweep)",
outbox_id, exc_info=True,
)
def deliver_file_row(db: Session, row: EfcFileOutbox, client: Optional[EfcClient] = None) -> None:
"""Sube el archivo de ``row.s3_key`` al expediente de EFC y, si ``delete_local``, borra la copia.
**Ensure-then-upload**: si EFC contesta 404 ``expediente_no_encontrado``, la creación del
provisional puede venir en camino (el outbox de expedientes y el de archivos son colas
distintas), así que se asegura el expediente y se reintenta el upload **una** vez.
**No lanza nunca**: como ``deliver_row``, registra el fallo en la propia fila.
"""
client = client or efc_client
if not client.is_configured or row.status == STATUS_SENT:
return
try:
org_id = _resolve_org_id(client, row.tenant_id)
expediente = db.query(Expediente).filter(Expediente.id == row.expediente_ref).first()
if expediente is None:
raise EfcClientError(
f"expediente {row.expediente_ref} no encontrado para el archivo '{row.kind}'",
retryable=True,
)
from core.storage_s3 import get_object_bytes
content = get_object_bytes(row.s3_key)
ct = row.content_type or "application/octet-stream"
try:
resp = client.upload_documento(
org_id, row.company_id, expediente.id, row.efc_tipo,
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
)
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({
"source": "crm",
"crm_tenant_slug": (_tenant_slug(row.tenant_id)[0] or ""),
"crm_company_id": row.company_id,
"crm_expediente_id": expediente.id,
"folio": expediente.folio,
"storage_token": expediente.efc_storage_token,
"organizacion": {"efc_organizacion_id": org_id},
})
resp = client.upload_documento(
org_id, row.company_id, expediente.id, row.efc_tipo,
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
)
else:
raise
doc_id = resp.get("id") if isinstance(resp, dict) else None
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(
"expediente_gateway: no se pudo borrar el archivo local %s (ya en EFC)",
row.s3_key, exc_info=True,
)
row.status = STATUS_SENT
row.sent_at = datetime.now(timezone.utc)
row.efc_document_id = doc_id
db.commit()
_marcar_documento_entregado(db, row, doc_id)
logger.info(
"expediente_gateway: archivo entregado row=%s kind=%s efc_document_id=%s",
row.id, row.kind, doc_id,
)
except EfcClientError as exc:
_register_file_failure(db, row, exc, exc.retryable)
except Exception as exc: # noqa: BLE001
_register_file_failure(db, row, exc, True)
def _register_file_failure(db: Session, row: EfcFileOutbox, exc: Exception, retryable: bool) -> None:
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()
_marcar_documento_fallido(db, row, exc)
logger.warning(
"expediente_gateway: entrega de archivo falló row=%s attempts=%s status=%s: %s",
row.id, row.attempts, row.status, exc,
)
# El mapa (kind, source_table) -> modelo del documento de origen. Un par que NO esté aquí **no toca
# nada**, en vez de caer por omisión sobre una tabla cualquiera: escribir con el id de otra tabla
# vaciaría las columnas de un documento ajeno que tuviera ese mismo entero — daño en el dato de otro,
# sin un solo error visible.
def _modelo_de_origen(source_table: str):
if source_table == "crm.documents":
from ..documents.models import Document
return Document
if source_table == "ops.shipment_documents":
from api.v1.modules.ops.shipments.models import ShipmentDocument
return ShipmentDocument
return None
def _fila_de_origen(db: Session, row: EfcFileOutbox):
modelo = _modelo_de_origen(row.source_table)
if modelo is None or row.source_id is None:
return None
return (
db.query(modelo)
.filter(
modelo.id == row.source_id,
modelo.tenant_id == row.tenant_id,
modelo.company_id == row.company_id,
)
.first()
)
def _marcar_documento_entregado(db: Session, row: EfcFileOutbox, doc_id) -> None:
"""Cierra la entrega en la fila del documento: el badge de la UI pasa a «En expediente»."""
documento = _fila_de_origen(db, row)
if documento is None:
return
documento.efc_document_id = str(doc_id) if doc_id else None
documento.efc_sync_state = "SYNCED"
documento.efc_synced_at = datetime.now(timezone.utc)
documento.efc_error_code = None
documento.efc_error_detail = None
if row.delete_local:
# El objeto local ya no está: dejar la key apuntaría a algo inexistente y la descarga se
# ramificaría por el camino equivocado.
documento.file_key = None
db.commit()
def _marcar_documento_fallido(db: Session, row: EfcFileOutbox, exc: Exception) -> None:
"""Refleja el fallo en la fila del documento para que la ficha lo muestre sin ir a los logs."""
documento = _fila_de_origen(db, row)
if documento is None:
return
documento.efc_attempts = row.attempts
documento.efc_error_detail = str(exc)[:2000]
documento.efc_error_code = getattr(exc, "code", None)
if row.status == STATUS_FAILED:
documento.efc_sync_state = "FAILED"
db.commit()
# ══ Organización ════════════════════════════════════════════════════════════
def _resolve_org_id(client: EfcClient, tenant_id: int) -> str:
slug, name = _tenant_slug(tenant_id)
if not slug:
raise EfcClientError(
f"tenant {tenant_id} sin slug; no se puede resolver la organización EFC.",
retryable=False,
)
if slug in _org_id_cache:
return _org_id_cache[slug]
resp = client.resolve_organizacion(slug, name)
org_id = resp.get("id") if isinstance(resp, dict) else None
if not org_id:
raise EfcClientError("El resolver de organización de EFC no devolvió id.", retryable=True)
_org_id_cache[slug] = org_id
return org_id
def _tenant_slug(tenant_id: int) -> tuple[Optional[str], Optional[str]]:
from api.v1.modules.core.tenants.models import Tenant
with scoped_core_db(tenant_id=tenant_id) as db:
t = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if t is None:
return None, None
return t.slug, t.name
# ══ Tablero de ops ══════════════════════════════════════════════════════════
def _outbox_to_dict(r: EfcSyncOutbox) -> dict:
return {
"id": r.id,
"tabla": "sync",
"kind": r.kind,
"status": r.status,
"attempts": r.attempts,
"last_error": r.last_error,
"expediente_ref": r.expediente_ref,
"efc_pedimento_id": r.efc_pedimento_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
}
def _file_outbox_to_dict(r: EfcFileOutbox) -> dict:
return {
"id": r.id,
"tabla": "file",
"kind": r.kind,
"status": r.status,
"attempts": r.attempts,
"last_error": r.last_error,
"expediente_ref": r.expediente_ref,
"file_name": r.file_name,
"efc_tipo": r.efc_tipo,
"source_table": r.source_table,
"source_id": r.source_id,
"crm_document_ref": r.crm_document_ref,
"efc_document_id": r.efc_document_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
}
def list_outbox(db: Session, tenant_id: int, company_id: int, tipo: Optional[str] = None,
status: Optional[str] = None, limit: int = 100) -> list[dict]:
"""Lista filas de los DOS outbox para el tablero de ops. ``tipo`` ∈ ``sync`` | ``file``."""
salida: list[dict] = []
if tipo in (None, "", "sync"):
q = db.query(EfcSyncOutbox).filter(
EfcSyncOutbox.tenant_id == tenant_id, EfcSyncOutbox.company_id == company_id
)
if status:
q = q.filter(EfcSyncOutbox.status == status)
salida += [
_outbox_to_dict(r)
for r in q.order_by(EfcSyncOutbox.created_at.desc()).limit(limit).all()
]
if tipo in (None, "", "file"):
q = db.query(EfcFileOutbox).filter(
EfcFileOutbox.tenant_id == tenant_id, EfcFileOutbox.company_id == company_id
)
if status:
q = q.filter(EfcFileOutbox.status == status)
salida += [
_file_outbox_to_dict(r)
for r in q.order_by(EfcFileOutbox.created_at.desc()).limit(limit).all()
]
salida.sort(key=lambda d: (d.get("created_at") or ""), reverse=True)
return salida[:limit]
def retry_outbox_row(db: Session, outbox_id: int, tenant_id: int, company_id: int,
tipo: str = "file") -> bool:
"""Reintento manual: resetea la fila a ``pending`` (``attempts=0``) y la re-despacha.
Devuelve ``False`` si no existe para ese tenant/company — el llamador lo traduce a **404 con
mensaje específico**, no a un 200 silencioso: es contrato con el frontend, que pinta el botón
según lo que reciba.
"""
modelo = EfcSyncOutbox if tipo == "sync" else EfcFileOutbox
r = (
db.query(modelo)
.filter(modelo.id == outbox_id, modelo.tenant_id == tenant_id, modelo.company_id == company_id)
.first()
)
if r is None:
return False
r.status = STATUS_PENDING
r.attempts = 0
r.last_error = None
db.commit()
if tipo == "sync":
_dispatch_delivery(r.id, r.tenant_id, r.company_id)
else:
_reset_documento_pendiente(db, r)
_dispatch_file_delivery(r.id, r.tenant_id, r.company_id)
return True
def _reset_documento_pendiente(db: Session, row: EfcFileOutbox) -> None:
documento = _fila_de_origen(db, row)
if documento is None:
return
documento.efc_sync_state = "PENDING"
documento.efc_error_code = None
documento.efc_error_detail = None
db.commit()
def outbox_metrics(db: Session, tenant_id: int, company_id: int) -> dict:
"""Conteo de los dos outbox por status (monitoreo). Los conteos suman las dos tablas."""
from sqlalchemy import func
counts = {STATUS_PENDING: 0, STATUS_SENT: 0, STATUS_FAILED: 0}
for modelo in (EfcSyncOutbox, EfcFileOutbox):
rows = (
db.query(modelo.status, func.count())
.filter(modelo.tenant_id == tenant_id, modelo.company_id == company_id)
.group_by(modelo.status)
.all()
)
for estado, n in rows:
counts[estado] = counts.get(estado, 0) + n
return {
"pending": counts.get(STATUS_PENDING, 0),
"sent": counts.get(STATUS_SENT, 0),
"failed": counts.get(STATUS_FAILED, 0),
}
def find_expediente_gaps(db: Session, limit: int = 200) -> list:
"""Expedientes (no borrados) SIN ninguna fila de outbox que los referencie.
Nunca se encolaron: expedientes creados **antes** de activar la integración, o un crash. Se
re-encolan para no perder la réplica.
Los ``failed`` **no son huecos** —existen como fila, son visibles y reintentables desde el
tablero—, así que la fila los excluye por estar presente, no por su estado. Corre sin contexto
de tenant (beat); cada expediente lleva el suyo.
"""
from sqlalchemy import exists
ya_encolado = exists().where(EfcSyncOutbox.expediente_ref == Expediente.id)
return (
db.query(Expediente)
.filter(Expediente.deleted_at.is_(None), ~ya_encolado)
.order_by(Expediente.id.desc())
.limit(limit)
.all()
)

View File

@@ -1,122 +0,0 @@
"""Tareas Celery del carril CRM Agentes de Carga -> EFC.
- ``deliver_outbox_row`` / ``sweep_outbox``: expedientes (alta del provisional y completado).
- ``deliver_file_outbox_row`` / ``sweep_file_outbox``: archivos.
- ``sweep_expediente_gaps``: reconciliación de expedientes que nunca se encolaron.
**La trampa de RLS, que es lo que más fácil se pasa por alto.** ``core/celery_app.py`` materializa el
contexto desde los headers ``rls_tenant_id`` / ``rls_company_id``. Por tanto:
- Las tareas **por fila** se despachan siempre con esos headers.
- 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 vería nada o se saltaría el aislamiento.
**Sin ``autoretry_for``, ``retry_backoff`` ni ``max_retries``**: duplicarían el mecanismo de
reintento que ya está en el cliente (3 intentos con backoff lineal) y en el barrido (cada 120 s
hasta ``MAX_ATTEMPTS``).
"""
import logging
from core.celery_app import celery_app
from core.config import settings
from core.database import scoped_core_db
from . import service
from .models import STATUS_PENDING, EfcFileOutbox, EfcSyncOutbox
logger = logging.getLogger(__name__)
# ── Expedientes ─────────────────────────────────────────────────────────────
@celery_app.task(name="expediente_gateway.deliver_outbox_row")
def deliver_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
with scoped_core_db(tenant_id, company_id) as db:
row = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.id == outbox_id).first()
if row is None:
logger.warning(
"expediente_gateway: outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
)
return
service.deliver_row(db, row)
@celery_app.task(name="expediente_gateway.sweep_outbox")
def sweep_outbox(limit: int = 100) -> int:
"""Re-despacha filas pendientes de expediente. Sin contexto de tenant: cada fila lleva el suyo."""
with scoped_core_db() as db:
rows = (
db.query(EfcSyncOutbox.id, EfcSyncOutbox.tenant_id, EfcSyncOutbox.company_id)
.filter(EfcSyncOutbox.status == STATUS_PENDING)
.order_by(EfcSyncOutbox.created_at.asc())
.limit(limit)
.all()
)
for rid, tid, cid in rows:
deliver_outbox_row.apply_async(
args=[rid, tid, cid],
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
)
if rows:
logger.info("expediente_gateway: sweep (expedientes) re-despachó %s filas pendientes", len(rows))
return len(rows)
# ── Archivos ────────────────────────────────────────────────────────────────
@celery_app.task(name="expediente_gateway.deliver_file_outbox_row")
def deliver_file_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
with scoped_core_db(tenant_id, company_id) as db:
row = db.query(EfcFileOutbox).filter(EfcFileOutbox.id == outbox_id).first()
if row is None:
logger.warning(
"expediente_gateway: file outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
)
return
service.deliver_file_row(db, row)
@celery_app.task(name="expediente_gateway.sweep_file_outbox")
def sweep_file_outbox(limit: int = 100) -> int:
"""Re-despacha archivos pendientes (EFC o el broker caídos cuando el usuario subió el archivo)."""
with scoped_core_db() as db:
rows = (
db.query(EfcFileOutbox.id, EfcFileOutbox.tenant_id, EfcFileOutbox.company_id)
.filter(EfcFileOutbox.status == STATUS_PENDING)
.order_by(EfcFileOutbox.created_at.asc())
.limit(limit)
.all()
)
for rid, tid, cid in rows:
deliver_file_outbox_row.apply_async(
args=[rid, tid, cid],
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
)
if rows:
logger.info("expediente_gateway: sweep (archivos) re-despachó %s archivos pendientes", len(rows))
return len(rows)
# ── Reconciliación de huecos ────────────────────────────────────────────────
@celery_app.task(name="expediente_gateway.sweep_expediente_gaps")
def sweep_expediente_gaps(limit: int = 200) -> int:
"""Detecta expedientes que nunca se encolaron a EFC y los re-encola.
No-op si la integración está apagada.
"""
if not settings.EFC_API_URL:
return 0
n = 0
with scoped_core_db() as db:
gaps = service.find_expediente_gaps(db, limit=limit)
for expediente in gaps:
service.replicate_expediente_best_effort(db, expediente)
n += 1
if n:
db.commit()
if n:
logger.info("expediente_gateway: sweep de huecos re-encoló %s expedientes", n)
return n

View File

@@ -1,64 +0,0 @@
"""Catálogo CERRADO de tipos de documento que EFC acepta del CRM.
Estas 22 claves son **exactamente** las de ``TIPOS_DOCUMENTO_CRM`` en
``api/record/views_integrations_crm.py`` de EFC. La lista está duplicada a mano en dos repos con
despliegue independiente, así que ``tests/test_doc_types_paridad.py`` la fija: si alguien agrega un
tipo de un solo lado, ese test se pone rojo antes de que un documento se rechace en producción.
Por qué es un conjunto cerrado y no texto libre, a diferencia del carril de Anexo22 —que manda el
tipo suelto y deja que EFC lo resuelva por nombre—: en el CRM ``doc_type`` es ``String(60)`` /
``String(30)`` **sin validación de backend**, los catálogos viven solo en TypeScript
(``frontend/src/lib/api/crm/format.ts``). Un typo crearía un ``DocumentType`` basura en el catálogo
**global** de EFC, que es compartido por todas las organizaciones y no se limpia solo.
Las tres fuentes del CRM y su origen:
- ``crm.documents`` → ``DOC_TYPES`` de ``format.ts``
- ``ops.shipment_documents`` → ``SHIPMENT_DOC_TYPES`` del mismo archivo
- ``fin.invoices`` → el PDF de factura (``factura_venta``)
``otro`` existe en las dos listas del CRM y significa lo mismo en ambas: es una sola entrada.
"""
# --- crm.documents ---------------------------------------------------------------------------
_TIPOS_DOCUMENTOS_CLIENTE = (
"constancia_fiscal",
"acta_constitutiva",
"identificacion",
"comprobante_domicilio",
"contrato",
"presentacion",
"certificacion",
"licencia",
"convenio",
"tarifario",
)
# --- ops.shipment_documents ------------------------------------------------------------------
_TIPOS_DOCUMENTOS_EMBARQUE = (
"MBL",
"HBL",
"MAWB",
"HAWB",
"CMR",
"factura_comercial",
"packing_list",
"carta_encomienda",
"carta_garantia",
"certificado_permiso",
)
# --- fin.invoices ----------------------------------------------------------------------------
_TIPOS_FACTURACION = ("factura_venta",)
# --- común a varias fuentes -------------------------------------------------------------------
_TIPOS_COMUNES = ("otro",)
EFC_DOC_TYPES: frozenset[str] = frozenset(
_TIPOS_DOCUMENTOS_CLIENTE + _TIPOS_DOCUMENTOS_EMBARQUE + _TIPOS_FACTURACION + _TIPOS_COMUNES
)
def is_valid_doc_type(doc_type: str | None) -> bool:
"""``True`` si EFC va a aceptar ese tipo. Se valida en el CRM para no gastar un viaje de red."""
return bool(doc_type) and doc_type in EFC_DOC_TYPES

View File

@@ -1,106 +0,0 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class ExpedienteBase(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
status: str = Field("abierto", max_length=20)
class ExpedienteCreate(BaseModel):
"""Alta explícita de un expediente.
No lleva ``folio``: lo asigna el servidor con el contador de ``folio.py``. Aceptarlo del cliente
permitiría pisar el consecutivo de otro expediente.
"""
service_request_id: int | None = None
account_id: int | None = None
class ExpedienteUpdate(BaseModel):
account_id: int | None = None
status: str | None = Field(None, max_length=20)
class ExpedienteCompleteInput(BaseModel):
"""Data aduanera real con la que se completa un expediente provisional."""
patente: str = Field(..., max_length=20)
aduana: str = Field(..., max_length=10)
numero_pedimento: str = Field(..., max_length=20)
anio: int = Field(..., ge=1900, le=2999)
clave_pedimento: str | None = Field(None, max_length=10)
regimen: str | None = Field(None, max_length=10)
fecha_pago: date | None = None
rfc_importador: str | None = Field(None, max_length=20)
rfc_agente_aduanal: str | None = Field(None, max_length=100)
class ExpedienteResponse(ExpedienteBase):
model_config = ConfigDict(from_attributes=True)
id: int
folio: str
period_year: int
period_month: int
sequence: int
efc_organizacion_id: str | None = None
efc_pedimento_id: str | None = None
efc_storage_token: str | None = None
efc_link_state: str
efc_error_code: str | None = None
efc_error_detail: str | None = None
patente: str | None = None
aduana: str | None = None
numero_pedimento: str | None = None
anio: int | None = None
clave_pedimento: str | None = None
regimen: str | None = None
fecha_pago: date | None = None
rfc_importador: str | None = None
rfc_agente_aduanal: str | None = None
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class ExpedienteEnsureInput(BaseModel):
"""Entrada de ``POST /expedientes/ensure``: la solicitud a la que colgar el expediente."""
service_request_id: int
class ExpedienteDocumentResponse(BaseModel):
"""Documento de un expediente, tal como lo ve el frontend.
**No lleva ``file_key`` ni ``file_url`` a propósito.** La copia local es de tránsito y se borra
al confirmar la entrega a EFC, así que exponerla invitaría al frontend a guardarse una
referencia que va a dejar de existir. Para abrir el archivo está el proxy de descarga.
"""
model_config = ConfigDict(from_attributes=True)
id: int
expediente_id: int | None = None
doc_type: str
name: str
content_type: str | None = None
size_bytes: int | None = None
# Lo que pinta el badge de la ficha: PENDING | SYNCED | FAILED
efc_sync_state: str | None = None
efc_document_ref: str | None = None
efc_document_id: str | None = None
efc_error_code: str | None = None
efc_attempts: int | None = None
uploaded_by: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -1,95 +0,0 @@
"""Asignador del folio de expediente: ``EXP{YYYY}-{MM}-{NNN}``.
Un consecutivo por ``(tenant, company, mes)`` que reinicia cada mes. La disciplina es la misma 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.
"""
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import ExpedienteFolioCounter
# Ancho del consecutivo dentro del folio. Al pasar de 999 el folio crece a 4 dígitos en vez de
# truncarse o reiniciar: un folio ya comunicado al cliente no puede cambiar de forma.
_SEQ_WIDTH = 3
def format_folio(year: int, month: int, sequence: int) -> str:
"""``(2026, 8, 1)`` → ``"EXP2026-08-001"``. Única fuente del formato del folio."""
return f"EXP{year:04d}-{month:02d}-{sequence:0{_SEQ_WIDTH}d}"
def storage_token(company_id: int, folio: str) -> str:
"""``CRM-{company_id}-{folio}`` — la llave del pedimento provisional en EFC.
Empieza con letras, así que es imposible que colisione con la llave de un pedimento real, que
es ``^\\d{2}-\\d{2}-\\d{4}-\\d{7}$``. El ``company_id`` va dentro porque el puente con EFC es
tenant → organización 1:1 pero un tenant tiene N companies: sin él, dos companies del mismo
tenant generarían el mismo ``EXP2026-08-001`` y chocarían en el ``unique_together`` de EFC.
Cabe en los 25 caracteres de ``Pedimento.pedimento_app`` mientras el consecutivo no pase de 4
dígitos y el ``company_id`` de 7: ``CRM-`` (4) + company + ``-`` + ``EXP2026-08-001`` (14).
"""
return f"CRM-{company_id}-{folio}"
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.
Un rollback deja HUECO en la secuencia. Los huecos son aceptables; los duplicados no.
"""
today = on or date.today()
period = f"{today.year:04d}-{today.month:02d}"
# El constructor de upsert es por dialecto: PostgreSQL en producción, SQLite en las pruebas
# unitarias (tests/conftest.py). Se usa el constructor de SQLAlchemy y no SQL crudo porque el
# `schema_translate_map` de las pruebas solo traduce el schema `crm` si la tabla viaja como
# objeto; en un `text()` el nombre del schema queda escrito a mano y rompe en SQLite.
dialect = db.get_bind().dialect.name
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert as _insert
else:
from sqlalchemy.dialects.sqlite import insert as _insert
table = ExpedienteFolioCounter.__table__
stmt = _insert(table).values(
tenant_id=tenant_id, company_id=company_id, period=period, last_seq=1
)
stmt = stmt.on_conflict_do_update(
index_elements=["tenant_id", "company_id", "period"],
set_={"last_seq": table.c.last_seq + 1},
).returning(table.c.last_seq)
sequence = db.execute(stmt).scalar_one()
return format_folio(today.year, today.month, sequence), today.year, today.month, sequence
def peek_last_sequence(db: Session, tenant_id: int, company_id: int, on: date | None = None) -> int:
"""El último consecutivo entregado en ese mes, o ``0`` si todavía no hay ninguno.
Solo lectura y sin efecto sobre el contador: existe para diagnóstico y para las pruebas. Quien
necesite un folio usa :func:`next_folio`.
"""
today = on or date.today()
period = f"{today.year:04d}-{today.month:02d}"
value = db.execute(
select(ExpedienteFolioCounter.last_seq).where(
ExpedienteFolioCounter.tenant_id == tenant_id,
ExpedienteFolioCounter.company_id == company_id,
ExpedienteFolioCounter.period == period,
)
).scalar_one_or_none()
return int(value or 0)

View File

@@ -1,117 +0,0 @@
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Expediente(Base, TenantScopedMixin, TimestampMixin):
"""Expediente del CRM: el hilo documental de una operación, de la RFQ a la factura.
El ancla es la solicitud de servicio (``crm.service_requests``): un expediente por hilo
comercial, siguiendo la cadena que el CRM ya tiene. En EFC cada expediente se refleja como un
*pedimento provisional* cuyo ``pedimento_app`` es el ``efc_storage_token``, y cuando llega la
data aduanera real ese provisional se completa sin mover un solo archivo.
El folio va DESCOMPUESTO en ``period_year`` / ``period_month`` / ``sequence`` además de
guardarse armado en ``folio``: así el consecutivo es un constraint real de la base y no un
parse de string. ``uq_crm_expedientes_periodo_seq`` es la red de seguridad — si el contador se
corrompe, un folio duplicado falla ruidosamente en vez de mezclar dos expedientes.
Los campos ``efc_*`` son un ESPEJO de lo que hay en EFC, nunca el handle. El handle que el CRM
usa para hablar de este expediente es su ``folio`` y su ``id``: ``efc_pedimento_id`` es un cache
de la resolución y ``pedimento_app`` del lado de EFC es mutable —se reescribe al completar—, así
que apoyarse en él rompería en cuanto la data real llegue.
``efc_storage_token`` es INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda los
objetos de este expediente. Que no cambie nunca es lo que hace que completar el pedimento no
obligue a mover archivos.
"""
__tablename__ = "expedientes"
__table_args__ = (
UniqueConstraint("tenant_id", "company_id", "folio", name="uq_crm_expedientes_folio"),
UniqueConstraint(
"tenant_id",
"company_id",
"period_year",
"period_month",
"sequence",
name="uq_crm_expedientes_periodo_seq",
),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# ── Folio ──────────────────────────────────────────────────────────────────────────────
folio: Mapped[str] = mapped_column(String(20), nullable=False, index=True) # EXP2026-08-001
period_year: Mapped[int] = mapped_column(Integer, nullable=False)
period_month: Mapped[int] = mapped_column(Integer, nullable=False)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
# ── Anclas comerciales ─────────────────────────────────────────────────────────────────
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
# abierto | completado | cerrado
status: Mapped[str] = mapped_column(
String(20), nullable=False, server_default=text("'abierto'"), index=True
)
# ── Espejo de EFC ──────────────────────────────────────────────────────────────────────
efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
efc_storage_token: Mapped[str | None] = mapped_column(String(25), nullable=True)
# PENDING | LINKED | FAILED
efc_link_state: Mapped[str] = mapped_column(
String(20), nullable=False, server_default=text("'PENDING'")
)
# El diagnóstico se guarda en la fila para que se vea en la ficha, sin obligar a ir a los logs.
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
# ── Data aduanera real: se llena al completar, no al crear ─────────────────────────────
# Longitudes tomadas de api/customs/models.py::Pedimento en EFC, que es el destino de estos
# datos: patente 20, aduana 10, regimen 10, clave_pedimento 10, RFC del agente 100.
patente: Mapped[str | None] = mapped_column(String(20), nullable=True)
aduana: Mapped[str | None] = mapped_column(String(10), nullable=True)
numero_pedimento: Mapped[str | None] = mapped_column(String(20), nullable=True)
anio: Mapped[int | None] = mapped_column(Integer, nullable=True)
clave_pedimento: Mapped[str | None] = mapped_column(String(10), nullable=True)
regimen: Mapped[str | None] = mapped_column(String(10), nullable=True)
fecha_pago: Mapped[date | None] = mapped_column(Date, nullable=True)
rfc_importador: Mapped[str | None] = mapped_column(String(20), nullable=True)
rfc_agente_aduanal: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class ExpedienteFolioCounter(Base):
"""Contador de folios por ``(tenant, company, mes)``.
Tabla propia y no un ``max(sequence) + 1`` sobre ``crm.expedientes``: ese SELECT es exactamente
la carrera que hay que evitar. Aquí el consecutivo se reserva con un solo
``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` (ver ``folio.py``), que serializa sobre esta
fila y devuelve el valor ya incrementado.
No lleva los mixins de tenant ni de timestamps a propósito: ``tenant_id`` y ``company_id`` son
parte de la PK compuesta, y una fila de contador no tiene ciclo de vida propio que auditar.
"""
__tablename__ = "expediente_folio_counters"
__table_args__ = {"schema": "crm"}
tenant_id: Mapped[int] = mapped_column(
Integer, ForeignKey("core.tenants.id"), primary_key=True, nullable=False
)
company_id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False)
period: Mapped[str] = mapped_column(String(7), primary_key=True, nullable=False) # "2026-08"
last_seq: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))

View File

@@ -1,182 +0,0 @@
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.efc_client import EfcClientError
from core.security import get_current_user
from . import service
from .dto import (
ExpedienteCompleteInput,
ExpedienteDocumentResponse,
ExpedienteEnsureInput,
ExpedienteResponse,
)
router = APIRouter()
@router.get("/expedientes", response_model=list[ExpedienteResponse])
def list_expedientes(
company_id: int = Query(..., description="Company ID"),
service_request_id: int | None = Query(None, description="Filtrar por solicitud"),
account_id: int | None = Query(None, description="Filtrar por cliente"),
status_filter: str | None = Query(None, alias="status", description="abierto|completado|cerrado"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.list_expedientes(
db, tenant_id, company_id, service_request_id, account_id, status_filter
)
@router.get("/expedientes/{expediente_id}", response_model=ExpedienteResponse)
def get_expediente(
expediente_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_expediente(db, expediente_id, tenant_id, company_id)
@router.post("/expedientes/ensure", response_model=ExpedienteResponse)
def ensure_expediente(
payload: ExpedienteEnsureInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Devuelve el expediente de una solicitud, creándolo si hace falta. Idempotente.
Responde 200 y no 201 justamente porque es idempotente: el llamador no puede distinguir —ni le
importa— si el expediente ya estaba.
"""
tenant_id = current_user["tenant_id"]
user_id = current_user.get("sub") or current_user.get("id")
return service.ensure_expediente(db, payload.service_request_id, tenant_id, company_id, user_id)
@router.post("/expedientes/{expediente_id}/completar", response_model=ExpedienteResponse)
def complete_expediente(
expediente_id: int,
payload: ExpedienteCompleteInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
user_id = current_user.get("sub") or current_user.get("id")
return service.complete_expediente(db, expediente_id, payload, tenant_id, company_id, user_id)
@router.delete("/expedientes/{expediente_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_expediente(
expediente_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
service.delete_expediente(db, expediente_id, tenant_id, company_id)
# ── Documentos del expediente ────────────────────────────────────────────────
@router.get(
"/expedientes/{expediente_id}/documentos",
response_model=list[ExpedienteDocumentResponse],
)
def list_expediente_documents(
expediente_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.list_expediente_documents(db, expediente_id, tenant_id, company_id)
@router.post(
"/expedientes/{expediente_id}/documentos",
response_model=ExpedienteDocumentResponse,
status_code=status.HTTP_201_CREATED,
)
async def upload_expediente_document(
expediente_id: int,
file: UploadFile = File(...),
doc_type: str = Form(...),
name: str | None = Form(None),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Subida de un paso: guarda, registra y encola la entrega a EFC.
Responde **201 aunque EFC esté caído**: el archivo ya está a salvo en el CRM y el carril lo
entrega cuando EFC vuelva. Perder el trabajo del usuario porque un sistema de terceros no
contesta sería el peor intercambio posible.
"""
tenant_id = current_user["tenant_id"]
user_id = current_user.get("sub") or current_user.get("id")
return await service.attach_document(
db, expediente_id, file, doc_type, tenant_id, company_id, name, user_id
)
@router.get("/expedientes/{expediente_id}/documentos/{document_id}/archivo")
async def download_expediente_document(
expediente_id: int,
document_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Proxy de descarga hacia EFC, con streaming.
La traducción de errores es asimétrica **a propósito**: cualquier ``EfcClientError`` sale como
502 —es un fallo de la integración, no del usuario—, salvo un 404 de EFC, que sale como 404
porque significa que ese documento realmente no está.
Es ``async`` porque el servicio abre la conexión con EFC y **comprueba su status antes** de que
esta función devuelva la ``StreamingResponse``: una vez devuelta, el status ya se envió y
traducir el error sería tarde (ver ``service._abrir_upstream``).
"""
tenant_id = current_user["tenant_id"]
try:
iterador, content_type, filename = await service.stream_document(
db, expediente_id, document_id, tenant_id, company_id
)
except EfcClientError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND
if exc.status_code == 404
else status.HTTP_502_BAD_GATEWAY,
detail="No se pudo obtener el archivo del expediente electrónico.",
) from exc
return StreamingResponse(
iterador,
media_type=content_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete(
"/expedientes/{expediente_id}/documentos/{document_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def detach_expediente_document(
expediente_id: int,
document_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Desasocia el documento del CRM. **No lo destruye en EFC** (ver ``service.detach_document``)."""
tenant_id = current_user["tenant_id"]
service.detach_document(db, expediente_id, document_id, tenant_id, company_id)

View File

@@ -1,447 +0,0 @@
"""Servicio del expediente del CRM.
Funciones libres que reciben ``db, tenant_id, company_id``, como el resto de los módulos del repo.
"""
import hashlib
import uuid
from datetime import datetime, timezone
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from core.s3_keys import expediente_document_key
from core.storage_s3 import put_object_bytes
from ..documents.models import Document
from ..expediente_gateway import service as gateway
from ..expediente_gateway.models import FILE_KIND_DOCUMENTO, SOURCE_CRM_DOCUMENTS
from ..service_requests.models import ServiceRequest
from ..uploads.routes import leer_acotado, validar_extension
from .doc_types import is_valid_doc_type
from .dto import ExpedienteCompleteInput
from .folio import next_folio, storage_token
from .models import Expediente
def _get_service_request(db: Session, service_request_id: int, tenant_id: int, company_id: int) -> ServiceRequest:
obj = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == service_request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
return obj
def get_expediente(db: Session, expediente_id: int, tenant_id: int, company_id: int) -> Expediente:
obj = (
db.query(Expediente)
.filter(
Expediente.id == expediente_id,
Expediente.tenant_id == tenant_id,
Expediente.company_id == company_id,
Expediente.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def list_expedientes(
db: Session,
tenant_id: int,
company_id: int,
service_request_id: int | None = None,
account_id: int | None = None,
exp_status: str | None = None,
) -> list[Expediente]:
query = db.query(Expediente).filter(
Expediente.tenant_id == tenant_id,
Expediente.company_id == company_id,
Expediente.deleted_at.is_(None),
)
if service_request_id is not None:
query = query.filter(Expediente.service_request_id == service_request_id)
if account_id is not None:
query = query.filter(Expediente.account_id == account_id)
if exp_status:
query = query.filter(Expediente.status == exp_status)
return query.order_by(Expediente.created_at.desc()).all()
def find_by_service_request(
db: Session, service_request_id: int, tenant_id: int, company_id: int
) -> Expediente | None:
return (
db.query(Expediente)
.filter(
Expediente.service_request_id == service_request_id,
Expediente.tenant_id == tenant_id,
Expediente.company_id == company_id,
Expediente.deleted_at.is_(None),
)
.first()
)
def ensure_expediente_for_service_request(
db: Session,
service_request_id: int,
tenant_id: int,
company_id: int,
user_id: str | None = None,
account_id: int | None = None,
) -> Expediente:
"""Devuelve el expediente de esa solicitud, creándolo si todavía no existe. **Idempotente.**
**No hace commit**: hace ``flush`` sobre la sesión que recibe. La razón es que sus dos
llamadores —``create_service_request`` y ``create_from_opportunity``— lo invocan ANTES de su
propio commit, dentro de la misma transacción. Si esto commiteara por su cuenta, un fallo
posterior en el alta de la solicitud dejaría un expediente huérfano con su folio ya quemado.
Es la misma disciplina de ``next_folio`` y la del asignador de folios de Anexo22.
"""
existing = find_by_service_request(db, service_request_id, tenant_id, company_id)
if existing is not None:
return existing
folio, year, month, sequence = next_folio(db, tenant_id, company_id)
expediente = Expediente(
folio=folio,
period_year=year,
period_month=month,
sequence=sequence,
service_request_id=service_request_id,
account_id=account_id,
status="abierto",
efc_storage_token=storage_token(company_id, folio),
efc_link_state="PENDING",
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(expediente)
db.flush()
# Réplica hacia EFC: best-effort y en la MISMA transacción. Si EFC está apagado
# (``EFC_API_URL`` vacía) esto es un no-op y el expediente vive igual, solo en el CRM.
gateway.replicate_expediente_best_effort(db, expediente)
return expediente
def ensure_expediente(
db: Session,
service_request_id: int,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Expediente:
"""Variante de cara al usuario: valida la solicitud, asegura el expediente y **sí** commitea.
La usa el endpoint ``POST /expedientes/ensure``, donde la transacción empieza y termina aquí.
"""
solicitud = _get_service_request(db, service_request_id, tenant_id, company_id)
expediente = ensure_expediente_for_service_request(
db, solicitud.id, tenant_id, company_id, user_id, account_id=solicitud.account_id
)
db.commit()
db.refresh(expediente)
return expediente
def _campos_para_efc(campos: dict) -> dict:
"""Traduce los campos del expediente al vocabulario del contrato de EFC.
Las fechas van en ISO porque el payload del outbox se serializa a JSON, y un ``date`` de Python
no es serializable: sin esto la fila se encolaría bien y **fallaría al entregar**, que es el peor
momento para descubrirlo.
"""
salida = {}
for clave, valor in campos.items():
salida[clave] = valor.isoformat() if hasattr(valor, "isoformat") else valor
return salida
def complete_expediente(
db: Session,
expediente_id: int,
payload: ExpedienteCompleteInput,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Expediente:
"""Registra en el CRM la data aduanera real de un expediente.
Solo toca la fila del CRM. Completar el pedimento provisional del lado de EFC es una operación
aparte, del carril del gateway, porque puede fallar por causas de EFC —un pedimento real que ya
existe con esa llave— y eso no debe impedir que el CRM guarde lo que el usuario capturó.
"""
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
if expediente.status == "completado":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="El expediente ya está completado"
)
campos = payload.model_dump(exclude_unset=True)
for field, value in campos.items():
setattr(expediente, field, value)
expediente.status = "completado"
expediente.updated_by = user_id
# El completado del provisional en EFC va por el outbox, no inline: puede devolver 409 si allá
# ya existe ese pedimento real, y eso no debe impedir que el CRM guarde lo que se capturó.
gateway.enqueue_completar_best_effort(db, expediente, _campos_para_efc(campos))
db.commit()
db.refresh(expediente)
return expediente
def delete_expediente(db: Session, expediente_id: int, tenant_id: int, company_id: int) -> None:
"""Baja lógica. No propaga nada a EFC: el expediente electrónico se conserva."""
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
expediente.deleted_at = datetime.now(timezone.utc)
db.commit()
# ══ Documentos del expediente (fase 7) ══════════════════════════════════════
async def attach_document(
db: Session,
expediente_id: int,
file: UploadFile,
doc_type: str,
tenant_id: int,
company_id: int,
name: str | None = None,
user_id: str | None = None,
) -> Document:
"""Subida de UN paso: guarda el archivo, crea el documento y encola su entrega a EFC.
El orden importa y no es negociable:
1. Validar tipo y extensión **antes** de tocar el almacén, para no dejar un objeto huérfano.
2. Leer el archivo **acotado**: nunca ``await file.read()`` completo (ver ``leer_acotado``).
3. Escribir a MinIO.
4. Crear la fila del documento y la del outbox **en una sola transacción**, de modo que no
pueda existir un documento sin su intención de entrega ni al revés.
5. Despachar best-effort y responder 201 **pase lo que pase con EFC**.
El paso 5 es lo que hace que un EFC caído no le cueste su trabajo al usuario: la subida tiene
éxito y el documento queda en «Pendiente de enviar» hasta que el barrido lo entregue.
"""
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
if not is_valid_doc_type(doc_type):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Ese tipo de documento no está en el catálogo.",
)
validar_extension(file.filename)
contenido = await leer_acotado(file)
key = expediente_document_key(
tenant_id, company_id, expediente.id, uuid.uuid4().hex, file.filename or "archivo"
)
content_type = file.content_type or "application/octet-stream"
put_object_bytes(key, contenido, content_type=content_type)
documento = Document(
doc_type=doc_type,
name=name or file.filename or "archivo",
file_key=key,
content_type=content_type,
size_bytes=len(contenido),
expediente_id=expediente.id,
efc_sync_state="PENDING",
content_sha256=hashlib.sha256(contenido).hexdigest(),
tenant_id=tenant_id,
company_id=company_id,
uploaded_by=user_id,
)
db.add(documento)
db.flush()
documento.efc_document_ref = f"CRMDOC-{company_id}-{documento.id}"
fila = gateway.enqueue_file_best_effort(
db,
kind=FILE_KIND_DOCUMENTO,
s3_key=key,
file_name=file.filename or "archivo",
content_type=content_type,
efc_tipo=doc_type,
source_table=SOURCE_CRM_DOCUMENTS,
source_id=documento.id,
crm_document_ref=documento.efc_document_ref,
expediente_ref=expediente.id,
tenant_id=tenant_id,
company_id=company_id,
delete_local=True,
)
db.commit()
db.refresh(documento)
if fila is not None:
gateway._dispatch_file_delivery(fila.id, tenant_id, company_id)
return documento
def get_expediente_document(
db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int
) -> Document:
"""El documento, validando que pertenezca a ESE expediente, tenant y company.
La validación de pertenencia va **antes** de tocar EFC. Sin ella, un ``document_id`` que
coincidiera leería el expediente de otro tenant — y peor: el ``organizacion_id`` con el que el
proxy pregunta se deriva del expediente, así que el CRM iría a preguntarle a la organización de
otro cliente.
"""
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
documento = (
db.query(Document)
.filter(
Document.id == document_id,
Document.expediente_id == expediente.id,
Document.tenant_id == tenant_id,
Document.company_id == company_id,
Document.deleted_at.is_(None),
)
.first()
)
if not documento:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
return documento
def list_expediente_documents(
db: Session, expediente_id: int, tenant_id: int, company_id: int
) -> list[Document]:
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
return (
db.query(Document)
.filter(
Document.expediente_id == expediente.id,
Document.tenant_id == tenant_id,
Document.company_id == company_id,
Document.deleted_at.is_(None),
)
.order_by(Document.created_at.desc())
.all()
)
async def _abrir_upstream(url: str, headers: dict, params: dict, verify: bool, timeout_s: float):
"""Abre la descarga contra EFC y **valida el status antes de devolver el generador**.
Que la validación ocurra aquí y no dentro del generador **no es cosmético**: Starlette manda la
línea de estado en cuanto construye la ``StreamingResponse``, o sea **antes** de pedir el primer
trozo. Un 404 de EFC detectado ya dentro del generador llega tarde —la respuesta salió con 200—
y el usuario recibe un archivo cortado en lugar del error. Detectándolo antes, la traducción
404→404 / resto→502 que fija el ticket llega de verdad al cliente.
**El ``AsyncClient`` se crea aquí y se cierra en el ``finally`` del generador**, no en un
``async with`` de fuera: FastAPI consume el generador después de que esta función retorna, así
que un cierre anticipado mataría la descarga a media transferencia.
EFC nunca entrega una URL de MinIO, y reescribir el host de una URL ya firmada invalida su
SigV4, así que el CRM tiene que hacer de segundo proxy: no hay atajo.
"""
import httpx
client = httpx.AsyncClient(verify=verify, timeout=timeout_s)
try:
peticion = client.build_request("GET", url, headers=headers, params=params)
upstream = await client.send(peticion, stream=True)
except httpx.HTTPError as exc:
# Un fallo de red es de la integración, no del usuario: 502, nunca un 500 opaco. Y el
# cliente se cierra aquí porque todavía no hay generador que lo haga.
await client.aclose()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="No se pudo obtener el archivo del expediente electrónico.",
) from exc
except BaseException:
await client.aclose()
raise
if upstream.status_code >= 400:
codigo = upstream.status_code
await upstream.aread()
await upstream.aclose()
await client.aclose()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND
if codigo == 404
else status.HTTP_502_BAD_GATEWAY,
detail="No se pudo obtener el archivo del expediente electrónico.",
)
async def _generador():
try:
async for chunk in upstream.aiter_bytes():
yield chunk
finally:
await upstream.aclose()
await client.aclose()
return _generador()
async def stream_document(
db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int
) -> tuple:
"""Prepara la descarga de un documento del expediente.
Devuelve ``(iterador, content_type, filename)``. Dos guardas, y las dos importan:
1. La pertenencia se valida **antes** de tocar EFC (ver ``get_expediente_document``).
2. Se manda el ``organizacion_id`` del expediente, para que la verificación de EFC también
dispare y no baste con acertar un id de documento.
"""
from core.efc_client import EfcClientError, efc_client
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
documento = get_expediente_document(db, expediente_id, document_id, tenant_id, company_id)
if not documento.efc_document_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El documento todavía no llegó al expediente electrónico.",
)
if not efc_client.is_configured:
raise EfcClientError("Integración con EFC no configurada.", retryable=False)
return (
await _abrir_upstream(
efc_client.download_url(documento.efc_document_id),
efc_client.auth_headers,
{"organizacion_id": str(expediente.efc_organizacion_id or "")},
efc_client.verify_ssl,
efc_client.upload_timeout_s,
),
documento.content_type or "application/octet-stream",
documento.name or "documento",
)
def detach_document(
db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int
) -> None:
"""Baja lógica en el CRM. **NO destruye el documento en EFC.**
El gateway de Anexo22 nunca llama al DELETE de EFC —verificado: ese método no existe en su
cliente—, y ``record.Document`` en EFC no tiene vigencia ni purga, así que la política implícita
del sistema es **conservar**. Un documento que mañana puede ser parte del expediente de un
pedimento real es riesgo de retención fiscal: desaparece de la vista del CRM y sigue en EFC.
"""
documento = get_expediente_document(db, expediente_id, document_id, tenant_id, company_id)
documento.deleted_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
phone: str | None
company_name: str | None
source: str | None
preferred_contact_method: str | None = None
status: str
estimated_value: Decimal | None
owner_user_id: str | None

View File

@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)

View File

@@ -17,6 +17,7 @@ class OpportunityCreate(BaseModel):
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
operation_type: str | None = Field(None, max_length=20) # importacion | exportacion
class OpportunityUpdate(BaseModel):
@@ -30,10 +31,13 @@ class OpportunityUpdate(BaseModel):
probability: int | None = Field(None, ge=0, le=100)
status: str | None = Field(None, max_length=20)
expected_close_date: date | None = None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
operation_type: str | None = Field(None, max_length=20)
class OpportunityMove(BaseModel):
@@ -57,10 +61,16 @@ class OpportunityResponse(BaseModel):
status: str
expected_close_date: date | None
closed_at: datetime | None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None
source: str | None
owner_user_id: str | None
notes: str | None
operation_type: str | None = None
reference: str | None = None
case_id: int | None = None
converted_service_request_id: int | None = None
tenant_id: int
company_id: int
created_at: datetime

View File

@@ -34,7 +34,18 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó
lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O...
# Expediente (hilo maestro del trámite); nace aquí y se hereda hacia abajo
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True)
# Solicitud generada al convertir la oportunidad (back-link idempotente)
converted_service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True
)

View File

@@ -1,9 +1,11 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..pipelines.models import Pipeline, PipelineStage
from .dto import OpportunityCreate, OpportunityUpdate
@@ -46,14 +48,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None:
opportunity.status = "won"
opportunity.probability = 100
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.won_date = opportunity.won_date or date.today()
opportunity.lost_date = None
elif stage.is_lost:
opportunity.status = "lost"
opportunity.probability = 0
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.lost_date = opportunity.lost_date or date.today()
opportunity.won_date = None
else:
opportunity.status = "open"
opportunity.probability = stage.probability
opportunity.closed_at = None
opportunity.won_date = None
opportunity.lost_date = None
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
@@ -149,6 +157,15 @@ def create_opportunity(
if opportunity.stage_id is not None:
stage = _get_scoped_stage(db, opportunity.stage_id, tenant_id, company_id)
_apply_stage_state(opportunity, stage)
# Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo.
if not opportunity.reference:
opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type)
# Expediente: nace con la oportunidad y se hereda a solicitud/cotización/operación/factura
if not opportunity.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=opportunity.account_id, title=opportunity.name, stage="oportunidad",
)
opportunity.case_id = case.id
db.add(opportunity)
db.commit()
db.refresh(opportunity)

View File

@@ -16,7 +16,6 @@ _ENTITIES = [
("contact", "contactos"),
("address", "direcciones"),
("document", "documentos"),
("expediente", "expedientes"),
("service_request", "solicitudes de servicio"),
("rate_request", "solicitudes de tarifa"),
("quote", "cotizaciones"),

View File

@@ -56,6 +56,7 @@ class QuoteBase(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
currency: str = Field("USD", max_length=3)
load_type: str | None = Field(None, max_length=10) # FCL | LCL (variante de la comparación "Ambas")
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
@@ -72,6 +73,7 @@ class QuoteUpdate(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
load_type: str | None = Field(None, max_length=10)
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
@@ -83,9 +85,12 @@ class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
service_request_reference: str | None = None # folio de la solicitud referenciada
case_id: int | None = None
status: str
total_cost: Decimal
total_sale: Decimal
pdf_file_key: str | None = None
sent_at: datetime | None = None
accepted_at: datetime | None = None
rejected_at: datetime | None = None
@@ -100,3 +105,30 @@ class QuoteResponse(QuoteBase):
@property
def margin(self) -> Decimal:
return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0))
# ----- Configuración de marca del formato de cotización -----
class QuoteSettingsInput(BaseModel):
emitter_name: str | None = Field(None, max_length=255)
emitter_rfc: str | None = Field(None, max_length=13)
emitter_address: str | None = None
emitter_phone: str | None = Field(None, max_length=60)
emitter_email: str | None = Field(None, max_length=255)
emitter_website: str | None = Field(None, max_length=255)
accent_color: str | None = Field(None, max_length=9)
quote_prefix: str | None = Field(None, max_length=12)
default_terms: str | None = None
footer_note: str | None = None
class QuoteSettingsResponse(QuoteSettingsInput):
model_config = ConfigDict(from_attributes=True)
id: int | None = None
logo_file_key: str | None = None
class SendQuoteEmailRequest(BaseModel):
to: str | None = None
subject: str | None = None
message: str | None = None

View File

@@ -15,6 +15,7 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
@@ -22,6 +23,8 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'"))
# Variante de carga cuando la solicitud es "Ambas": FCL | LCL (NULL si no aplica)
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
# borrador | enviada | aceptada | rechazada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
@@ -34,10 +37,35 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Clave del PDF generado en MinIO (para regenerar/enviar)
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class QuoteSettings(Base, TenantScopedMixin, TimestampMixin):
"""Configuración de marca del formato de cotización, por compañía (tenant).
Encabezado del emisor, logo y textos por defecto que se imprimen en el PDF.
"""
__tablename__ = "quote_settings"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
emitter_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
emitter_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True)
emitter_address: Mapped[str | None] = mapped_column(Text, nullable=True)
emitter_phone: Mapped[str | None] = mapped_column(String(60), nullable=True)
emitter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
emitter_website: Mapped[str | None] = mapped_column(String(255), nullable=True)
logo_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
accent_color: Mapped[str | None] = mapped_column(String(9), nullable=True, server_default=text("'#2f6bf0'"))
quote_prefix: Mapped[str | None] = mapped_column(String(12), nullable=True, server_default=text("'COT'"))
default_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
class QuoteItem(Base, TenantScopedMixin, TimestampMixin):
"""Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros)."""

View File

@@ -0,0 +1,376 @@
"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema.
Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección,
tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado
(JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant.
"""
from __future__ import annotations
import io
from decimal import Decimal
_W = 612
_H = 792
_ML = 50 # margen izquierdo
_MR = 562 # margen derecho (x)
CONCEPT_LABELS = {
"flete_internacional": "Flete internacional",
"transporte_terrestre": "Transporte terrestre",
"despacho_aduanal": "Despacho aduanal",
"gastos_destino": "Gastos en destino",
"otros": "Otros cargos",
}
_TRANSLATE = str.maketrans({"": "-", "": "-", "": '"', "": '"', "": "'", "": "'", "": "-", "": "...", "\t": " "})
def _esc(text) -> str:
s = ("" if text is None else str(text)).translate(_TRANSLATE)
s = s.encode("latin-1", "replace").decode("latin-1")
return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
def _money(value) -> str:
return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}"
def _num(value) -> str:
return f"{Decimal(str(value or 0)):,.2f}"
# Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar)
def _text_w(s: str, size: float, bold: bool = False) -> float:
return len(s) * size * (0.56 if bold else 0.52)
def _fit(s: str, size: float, max_w: float) -> str:
s = s or ""
if _text_w(s, size) <= max_w:
return s
while s and _text_w(s + "", size) > max_w:
s = s[:-1]
return s + ""
def _wrap(text: str, width_chars: int) -> list[str]:
words = (text or "").split()
if not words:
return []
out, cur = [], ""
for w in words:
cand = f"{cur} {w}".strip()
if len(cand) > width_chars and cur:
out.append(cur)
cur = w
else:
cur = cand
if cur:
out.append(cur)
return out
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
try:
h = (hexs or "#12294c").lstrip("#")
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
except Exception:
return (0.07, 0.16, 0.30)
def _prep_logo(logo_bytes: bytes | None):
if not logo_bytes:
return None
try:
from PIL import Image
im = Image.open(io.BytesIO(logo_bytes)).convert("RGB")
im.thumbnail((600, 300))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=88)
return buf.getvalue(), im.width, im.height
except Exception:
return None
class _Canvas:
"""Acumula operadores de contenido con paginación simple."""
def __init__(self):
self.pages: list[list[str]] = [[]]
self.y = _H
@property
def ops(self) -> list[str]:
return self.pages[-1]
def new_page(self):
self.pages.append([])
self.y = _H - 50
def ensure(self, needed: float):
if self.y - needed < 50:
self.new_page()
def rect(self, x, y, w, h, rgb):
r, g, b = rgb
self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f")
def line(self, x1, y1, x2, y2, rgb, width=0.6):
r, g, b = rgb
self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S")
def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False):
font = "F2" if bold else "F1"
r, g, b = rgb
tx = x - _text_w(str(s), size, bold) if right else x
self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET")
def build_quote_pdf(
*,
emitter: dict,
head: dict,
client: dict,
cargo: list[tuple[str, str]],
route: list[tuple[str, str]],
items: list[dict],
currency: str,
subtotal,
terms: str | None,
footer: str | None,
logo_bytes: bytes | None = None,
accent: str | None = "#12294c",
) -> bytes:
ACC = _hex_rgb(accent)
INK = (0.10, 0.15, 0.24)
GRAY = (0.42, 0.47, 0.55)
LINE = (0.80, 0.84, 0.90)
ZEBRA = (0.955, 0.965, 0.980)
logo = _prep_logo(logo_bytes)
c = _Canvas()
# ---------------- Encabezado ----------------
c.rect(0, _H - 12, _W, 12, ACC) # banda superior
logo_bottom = _H - 95
if logo:
_, lw, lh = logo
dw, dh = 150.0, 150.0 * lh / lw
if dh > 55:
dh, dw = 55.0, 55.0 * lw / lh
c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q")
else:
c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True)
# Emisor (derecha)
ex, ey = 320, _H - 42
c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True)
ey -= 14
em_lines = []
if emitter.get("rfc"):
em_lines.append(f"RFC: {emitter['rfc']}")
for a in (emitter.get("address") or "").splitlines():
if a.strip():
em_lines.append(a.strip())
contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x])
if contact:
em_lines.append(contact)
for ln in em_lines[:5]:
c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY)
ey -= 11
# Título + regla
c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True)
c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2)
# Panel de datos (derecha)
px, pw = 320, _MR - 320
py_top = _H - 128
ph = 74
c.rect(px, py_top - ph, pw, ph, ZEBRA)
c.line(px, py_top, px, py_top - ph, LINE)
hy = py_top - 15
info = [
("No.", head.get("reference") or "-"),
("Fecha", head.get("issue_date") or "-"),
("Vigencia", head.get("valid_until") or "-"),
("Ejecutivo", head.get("owner") or "-"),
("Estatus", str(head.get("status") or "-").capitalize()),
]
for k, v in info:
c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True)
c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK)
hy -= 12.5
c.y = _H - 215
# ---------------- Helpers de sección ----------------
def section(title: str):
c.ensure(30)
c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC)
c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True)
c.y -= 26
def kv_block(pairs: list[tuple[str, str]]):
rows = [(k, v) for k, v in pairs if v not in (None, "", "None")]
if not rows:
return False
col_w = (_MR - _ML) / 2
i = 0
while i < len(rows):
c.ensure(16)
for col in range(2):
if i + col < len(rows):
k, v = rows[i + col]
x = _ML + 6 + col * col_w
c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True)
c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK)
c.y -= 16
i += 2
c.y -= 4
return True
# ---------------- Cliente ----------------
section("Cliente")
if not kv_block([
("Cliente", client.get("name")), ("RFC", client.get("rfc")),
("Correo", client.get("email")), ("Teléfono", client.get("phone")),
]):
c.text(_ML + 6, c.y - 11, "", 9, GRAY)
c.y -= 16
# ---------------- Carga / Ruta (solo si hay datos) ----------------
if [v for _, v in cargo if v not in (None, "", "None")]:
section("Información de la carga")
kv_block(cargo)
if [v for _, v in route if v not in (None, "", "None")]:
section("Ruta logística")
kv_block(route)
# ---------------- Costos ----------------
section("Costos cotizados")
x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6
row_h = 18
# encabezado de tabla
c.ensure(row_h)
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC)
c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True)
c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True)
c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True)
c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True)
c.y -= row_h
z = False
for it in items:
code = str(it.get("concept") or "")
label = CONCEPT_LABELS.get(code, code)
desc = str(it.get("description") or "")
if desc:
label = f"{label} - {desc}"
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_sale") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
c.ensure(row_h)
if z:
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA)
c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK)
c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True)
c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True)
c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True)
c.y -= row_h
z = not z
if not items:
c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY)
c.y -= row_h
# borde de la tabla
c.line(_ML, c.y, _MR, c.y, LINE)
c.y -= 12
# ---------------- Totales (caja derecha) ----------------
tb_x, tb_w = 360, _MR - 360
c.ensure(58)
c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA)
c.line(tb_x, c.y, tb_x, c.y - 58, LINE)
ty = c.y - 16
c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True)
c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True)
ty -= 15
c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True)
c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True)
ty -= 6
c.rect(tb_x, ty - 20, tb_w, 20, ACC)
c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True)
c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True)
c.y -= 70
# ---------------- Condiciones ----------------
if terms:
section("Condiciones comerciales")
for para in terms.splitlines():
for ln in (_wrap(para, 108) or [""]):
c.ensure(13)
c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY)
c.y -= 12
c.y -= 4
# pie en todas las páginas
for ops in c.pages:
if footer:
r, g, b = GRAY
ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET")
ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f")
# ---------------- Ensamblado ----------------
streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages]
objects: list[bytes] = []
def add(obj: bytes):
objects.append(obj)
n_pages = len(c.pages)
has_img = 1 if logo else 0
# numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams
img_num = 5 if has_img else None
base = 6 if has_img else 5
page_nums = list(range(base, base + n_pages))
content_nums = list(range(base + n_pages, base + 2 * n_pages))
kids = " ".join(f"{n} 0 R" for n in page_nums)
add(b"<< /Type /Catalog /Pages 2 0 R >>")
add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1"))
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
if logo:
jpeg, lw, lh = logo
add(
(
f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} "
f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n"
).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream"
)
for i in range(n_pages):
res = "/Font << /F1 3 0 R /F2 4 0 R >>"
if has_img and i == 0:
res += f" /XObject << /Im0 {img_num} 0 R >>"
add(
(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] "
f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>"
).encode("latin-1")
)
for stream in streams:
add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
offsets = []
for i, obj in enumerate(objects, start=1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n"
xref_pos = len(out)
total = len(objects) + 1
out += f"xref\n0 {total}\n".encode("latin-1") + b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode("latin-1")
out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
return bytes(out)

View File

@@ -0,0 +1,250 @@
"""PDF de cotización, configuración de marca por tenant y envío por correo."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from .models import Quote, QuoteItem, QuoteSettings
from .pdf import build_quote_pdf
from .service import get_quote
logger = logging.getLogger(__name__)
DEFAULT_TERMS = (
"Tarifas sujetas a disponibilidad de espacio.\n"
"Cualquier variación en peso o volumen generará ajuste tarifario.\n"
"No incluye cargos extraordinarios, maniobras especiales o servicios no especificados.\n"
"Tarifas sujetas a revisión por parte de la línea transportista y autoridades correspondientes."
)
# ---------------- Configuración de marca ----------------
def get_settings(db: Session, tenant_id: int, company_id: int) -> QuoteSettings | None:
return (
db.query(QuoteSettings)
.filter(QuoteSettings.tenant_id == tenant_id, QuoteSettings.company_id == company_id,
QuoteSettings.deleted_at.is_(None))
.first()
)
def upsert_settings(db: Session, tenant_id: int, company_id: int, data: dict) -> QuoteSettings:
obj = get_settings(db, tenant_id, company_id)
if obj is None:
obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id)
db.add(obj)
for field, value in data.items():
if value is not None:
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> QuoteSettings:
obj = get_settings(db, tenant_id, company_id)
if obj is None:
obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id)
db.add(obj)
obj.logo_file_key = file_key
db.commit()
db.refresh(obj)
return obj
def _compose_place(city: str | None, country: str | None, port: str | None) -> str | None:
"""Arma 'Ciudad, PAÍS (Puerto)' con las partes que existan (ruta estructurada)."""
head = ", ".join(p for p in (city, country) if p)
if port:
head = f"{head} ({port})" if head else port
return head or None
def _company_row(db: Session, company_id: int) -> dict:
try:
row = db.execute(
text("SELECT name, rfc, logo FROM a76.company WHERE id = :c"), {"c": company_id}
).first()
if row:
return {"name": row[0], "rfc": row[1], "logo": row[2]}
except Exception:
pass
return {}
# ---------------- Construcción del PDF ----------------
def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) -> bytes:
items = (
db.query(QuoteItem)
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
.order_by(QuoteItem.id.asc())
.all()
)
account = (
db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None
)
sr = (
db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
if quote.service_request_id else None
)
settings = get_settings(db, tenant_id, company_id)
company = _company_row(db, company_id)
# Emisor: config del tenant con respaldo en a76.company
emitter = {
"name": (settings.emitter_name if settings else None) or company.get("name") or "Emisor",
"rfc": (settings.emitter_rfc if settings else None) or company.get("rfc"),
"address": settings.emitter_address if settings else None,
"phone": settings.emitter_phone if settings else None,
"email": settings.emitter_email if settings else None,
"website": settings.emitter_website if settings else None,
}
accent = (settings.accent_color if settings else None) or "#12294c"
prefix = (settings.quote_prefix if settings else None) or "COT"
terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS
footer = settings.footer_note if settings else None
# Logo (MinIO)
logo_bytes = None
logo_key = settings.logo_file_key if settings else None
if logo_key:
try:
from core.storage_s3 import get_object_bytes
logo_bytes = get_object_bytes(logo_key)
except Exception as exc:
logger.warning("No se pudo leer el logo del tarifario: %s", exc)
reference = quote.reference or f"{prefix}-{datetime.now().strftime('%Y%m%d')}-{quote.id:03d}"
head = {
"reference": reference,
"issue_date": quote.issue_date.isoformat() if quote.issue_date else None,
"valid_until": quote.valid_until.isoformat() if quote.valid_until else None,
"owner": quote.owner_user_id or "-",
"status": quote.status,
}
client = {
"name": account.name if account else None,
"rfc": account.rfc if account else None,
"email": account.email if account else None,
"phone": account.phone if account else None,
}
cargo = []
route = []
if sr:
cargo = [
("Tipo de mercancía", sr.cargo_type), ("Descripción", sr.commodity),
("Peso", str(sr.weight) if sr.weight is not None else None),
("Volumen", str(sr.volume) if sr.volume is not None else None),
("Tipo de carga", sr.load_type), ("Equipo", sr.container_equipment),
]
route = [
("Operación", sr.operation_type), ("Modo", sr.transport_mode),
("Servicio", sr.service_type), ("Incoterm", sr.incoterm),
("Origen", sr.origin or _compose_place(sr.origin_city, sr.origin_country, sr.origin_port)),
("Destino", sr.destination or _compose_place(sr.destination_city, sr.destination_country, sr.destination_port)),
("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None),
]
return build_quote_pdf(
emitter=emitter, head=head, client=client, cargo=cargo, route=route,
items=[{"concept": i.concept, "description": i.description, "quantity": i.quantity, "unit_sale": i.unit_sale} for i in items],
currency=quote.currency, subtotal=quote.total_sale, terms=terms, footer=footer,
logo_bytes=logo_bytes, accent=accent,
)
def _store_pdf(db: Session, quote: Quote, tenant_id: int, company_id: int, pdf_bytes: bytes) -> str:
from core.storage_s3 import put_object_bytes
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
key = f"tenants/{tenant_id}/companies/{company_id}/crm-quotes/{quote.id}/cotizacion-{ref}.pdf"
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
quote.pdf_file_key = key
db.commit()
return key
def get_pdf_url(db: Session, quote_id: int, tenant_id: int, company_id: int) -> str:
from core.storage_s3 import presigned_get_url
quote = get_quote(db, quote_id, tenant_id, company_id)
pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id)
key = _store_pdf(db, quote, tenant_id, company_id, pdf_bytes)
return presigned_get_url(key)
# ---------------- Envío por correo ----------------
async def send_quote_email(
db: Session, quote_id: int, tenant_id: int, company_id: int,
to: str | None, subject: str | None, message: str | None,
) -> dict:
import ssl
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from core.config import settings as cfg
quote = get_quote(db, quote_id, tenant_id, company_id)
account = db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None
recipient = to or (account.email if account else None)
if not recipient:
raise HTTPException(status_code=400, detail="No hay correo destino (captura uno o pon el correo del cliente).")
pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id)
_store_pdf(db, quote, tenant_id, company_id, pdf_bytes)
ref = quote.reference or f"COT-{quote.id}"
msg = MIMEMultipart()
msg["From"] = f"{cfg.SMTP_FROM_NAME} <{cfg.SMTP_USER}>"
msg["To"] = recipient
msg["Subject"] = subject or f"Cotización {ref}"
html = (
"<div style='font-family:Arial,sans-serif;color:#333;max-width:600px'>"
f"<p>{(message or 'Adjunto la cotización solicitada. Quedamos atentos.').replace(chr(10), '<br>')}</p>"
f"<p style='color:#6b7280;font-size:12px'>Cotización {ref}</p></div>"
)
msg.attach(MIMEText(html, "html"))
part = MIMEBase("application", "pdf")
part.set_payload(pdf_bytes)
encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="cotizacion-{ref}.pdf"')
msg.attach(part)
if not (cfg.SMTP_USER and cfg.SMTP_PASSWORD):
raise HTTPException(status_code=503, detail="El correo saliente (SMTP) no está configurado en el servidor.")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
# Puerto 465 = SSL implícito; los demás (587/2525/…) = STARTTLS.
await aiosmtplib.send(
msg,
hostname=cfg.SMTP_HOST,
port=cfg.SMTP_PORT,
username=cfg.SMTP_USER,
password=cfg.SMTP_PASSWORD,
use_tls=(cfg.SMTP_PORT == 465),
start_tls=(cfg.SMTP_PORT != 465),
tls_context=ctx,
validate_certs=False,
timeout=30,
)
except Exception as exc:
logger.error("Error enviando cotización %s: %s", quote_id, exc)
raise HTTPException(status_code=502, detail=f"No se pudo enviar el correo: {exc}")
# Marca como enviada
if quote.status == "borrador":
quote.status = "enviada"
quote.sent_at = datetime.now(timezone.utc)
db.commit()
return {"sent_to": recipient, "reference": ref}

View File

@@ -1,22 +1,76 @@
from fastapi import APIRouter, Depends, Query, status
from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from . import pdf_service, service
from .dto import (
QuoteCreate,
QuoteItemCreate,
QuoteItemResponse,
QuoteItemUpdate,
QuoteResponse,
QuoteSettingsInput,
QuoteSettingsResponse,
QuoteUpdate,
SendQuoteEmailRequest,
)
router = APIRouter()
# ----- Configuración de marca del formato de cotización -----
@router.get("/quote-settings", response_model=QuoteSettingsResponse)
def get_quote_settings(
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id)
return obj or QuoteSettingsResponse()
@router.put("/quote-settings", response_model=QuoteSettingsResponse)
def save_quote_settings(
payload: QuoteSettingsInput,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return pdf_service.upsert_settings(db, current_user["tenant_id"], company_id, payload.model_dump(exclude_unset=True))
@router.post("/quote-settings/logo", response_model=QuoteSettingsResponse)
async def upload_quote_logo(
company_id: int = Query(...),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
from core.storage_s3 import put_object_bytes
tenant_id = current_user["tenant_id"]
content = await file.read()
safe = (file.filename or "logo").replace("/", "-")
key = f"tenants/{tenant_id}/companies/{company_id}/crm-quote-logo/{safe}"
put_object_bytes(key, content, content_type=file.content_type or "image/png")
return pdf_service.set_logo_key(db, tenant_id, company_id, key)
@router.get("/quote-settings/logo-url")
def get_logo_url(
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
from core.storage_s3 import presigned_get_url
obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id)
if not obj or not obj.logo_file_key:
return {"url": None}
return {"url": presigned_get_url(obj.logo_file_key)}
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@@ -56,6 +110,24 @@ def create_quote(
return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user))
@router.post(
"/quotes/from-service-request",
response_model=list[QuoteResponse],
status_code=status.HTTP_201_CREATED,
)
def create_quotes_from_service_request(
service_request_id: int = Query(..., description="Solicitud de servicio a cotizar"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Genera la(s) cotización(es) desde una solicitud. Si es 'Ambas' devuelve 2 (FCL/LCL)."""
tenant_id = current_user["tenant_id"]
return service.create_quotes_from_service_request(
db, service_request_id, tenant_id, company_id, _user_id(current_user)
)
@router.patch("/quotes/{quote_id}", response_model=QuoteResponse)
def update_quote(
quote_id: int,
@@ -98,6 +170,39 @@ def reject_quote(
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.get("/quotes/{quote_id}/pdf")
def quote_pdf(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Devuelve el PDF de la cotización directamente (vía backend, sin exponer MinIO)."""
tenant_id = current_user["tenant_id"]
quote = service.get_quote(db, quote_id, tenant_id, company_id)
pdf_bytes = pdf_service.build_pdf_bytes(db, quote, tenant_id, company_id)
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="cotizacion-{ref}.pdf"'},
)
@router.post("/quotes/{quote_id}/send-email")
async def quote_send_email(
quote_id: int,
payload: SendQuoteEmailRequest,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Genera el PDF y lo envía por correo (al cliente o al destinatario indicado)."""
return await pdf_service.send_quote_email(
db, quote_id, current_user["tenant_id"], company_id, payload.to, payload.subject, payload.message
)
@router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
def clone_quote(
quote_id: int,

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
@@ -6,7 +6,11 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..cases import service as cases_service
from ..catalogs.models import CatalogItem
from ..common.folios import next_folio
from ..common.pricing import air_chargeable_kg
from ..service_requests.models import RateRequest, ServiceRequest
from ..suppliers.models import Supplier
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
from .models import Quote, QuoteItem
@@ -70,7 +74,18 @@ def get_quotes(
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
quotes = query.order_by(Quote.created_at.desc()).all()
# Enriquecer con el folio de la solicitud referenciada (para verlo en la lista)
sr_ids = {q.service_request_id for q in quotes if q.service_request_id}
if sr_ids:
refs = dict(
db.query(ServiceRequest.id, ServiceRequest.reference)
.filter(ServiceRequest.id.in_(sr_ids))
.all()
)
for q in quotes:
q.service_request_reference = refs.get(q.service_request_id)
return quotes
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
@@ -89,18 +104,142 @@ def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Qu
return obj
def _sr_direction(db: Session, service_request_id: int | None) -> str | None:
"""Dirección impo/expo heredada de la solicitud asociada (para el folio)."""
if not service_request_id:
return None
sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first()
return sr.operation_type if sr else None
def create_quote(
db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Fecha de la cotización: por defecto hoy si no se capturó
if obj.issue_date is None:
obj.issue_date = date.today()
# Folio C... auto-generado (mensual), con la dirección heredada de la solicitud
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id))
# Expediente heredado de la solicitud
if obj.service_request_id and not obj.case_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first()
if sr:
obj.case_id = sr.case_id
cases_service.advance_stage(db, obj.case_id, "cotizacion")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def create_quotes_from_service_request(
db: Session, service_request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> list[Quote]:
"""Genera cotización(es) a partir de una solicitud de servicio.
Si la solicitud es "Ambas" (FCL y LCL), genera **dos** cotizaciones (una por
variante) para comparar. Cada cotización toma su propio folio C... y hereda la
dirección impo/expo de la solicitud. Los conceptos se siembran desde las
solicitudes de tarifa (RateRequest) capturadas en la solicitud.
"""
sr = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == service_request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
variants = ["FCL", "LCL"] if (sr.load_type or "").upper() == "AMBAS" else [sr.load_type or None]
rate_requests = (
db.query(RateRequest)
.filter(
RateRequest.service_request_id == sr.id,
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
.all()
)
# Etiquetas legibles de los servicios adicionales (global + tenant) para los conceptos
service_labels = {
code: label
for code, label in db.query(CatalogItem.code, CatalogItem.label).filter(
CatalogItem.catalog == "servicio_adicional"
)
}
service_costs = sr.additional_service_costs or {}
created: list[Quote] = []
for variant in variants:
quote = Quote(
account_id=sr.account_id,
service_request_id=sr.id,
currency=sr.currency or "USD",
load_type=variant,
status="borrador",
issue_date=date.today(),
notes=sr.client_notes or sr.notes,
owner_user_id=sr.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type),
case_id=sr.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(quote)
db.flush()
for rr in rate_requests:
amount = rr.rate_amount if rr.rate_amount is not None else Decimal(0)
db.add(QuoteItem(
quote_id=quote.id, concept=rr.concept, description=rr.description,
supplier_id=rr.supplier_id, quantity=Decimal(1),
unit_cost=amount, unit_sale=amount, currency=rr.currency,
tenant_id=tenant_id, company_id=company_id,
))
# Servicios adicionales marcados en la solicitud → conceptos con su costo estimado
for code in (sr.additional_services or []):
amount = Decimal(str(service_costs.get(code) or 0))
db.add(QuoteItem(
quote_id=quote.id, concept=code[:60],
description=service_labels.get(code, "Servicio adicional"),
quantity=Decimal(1), unit_cost=amount, unit_sale=amount,
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
# Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad,
# para que el ejecutivo capture la tarifa por kg.
if (variant or "").upper() == "AEREO":
chargeable = air_chargeable_kg(
sr.weight, sr.length_cm, sr.width_cm, sr.height_cm,
sr.pallets_count or sr.pieces_count or 1,
)
db.add(QuoteItem(
quote_id=quote.id, concept="flete_internacional",
description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)",
quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0),
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute_totals(db, quote)
created.append(quote)
cases_service.advance_stage(db, sr.case_id, "cotizacion")
db.commit()
for quote in created:
db.refresh(quote)
return created
def update_quote(
db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:

View File

@@ -0,0 +1,178 @@
"""Schemas del módulo Tarifario."""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
# ---------- Quiebres y cargos ----------
class RateBreakDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)
from_qty: Decimal = Field(0)
rate: Decimal = Field(0)
class RateChargeDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)
concept: str = Field(..., max_length=60)
charge_type: str = Field("fijo", max_length=20)
value: Decimal | None = None
condition: str | None = None
class RateChargeCreate(BaseModel):
concept: str = Field(..., max_length=60)
charge_type: str = Field("fijo", max_length=20)
value: Decimal | None = None
condition: str | None = None
rate_lane_id: int | None = None
class RateChargeUpdate(BaseModel):
concept: str | None = Field(None, max_length=60)
charge_type: str | None = Field(None, max_length=20)
value: Decimal | None = None
condition: str | None = None
class RateChargeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
rate_sheet_id: int | None
rate_lane_id: int | None
concept: str
charge_type: str
value: Decimal | None
condition: str | None
# ---------- Rutas ----------
class RateLaneBase(BaseModel):
origin: str | None = Field(None, max_length=20)
destination: str | None = Field(None, max_length=20)
region: str | None = Field(None, max_length=60)
equipment_type: str | None = Field(None, max_length=20)
rate_unit: str | None = Field(None, max_length=20)
min_charge: Decimal | None = None
flat_rate: Decimal | None = None
transit_days: int | None = None
notes: str | None = None
class RateLaneCreate(RateLaneBase):
breaks: list[RateBreakDTO] = Field(default_factory=list)
class RateLaneUpdate(RateLaneBase):
breaks: list[RateBreakDTO] | None = None
class RateLaneResponse(RateLaneBase):
model_config = ConfigDict(from_attributes=True)
id: int
rate_sheet_id: int
breaks: list[RateBreakDTO] = Field(default_factory=list)
# ---------- Tarifario (cabecera) ----------
class RateSheetBase(BaseModel):
supplier_id: int | None = None
mode: str = Field(..., max_length=20)
name: str = Field(..., min_length=1, max_length=255)
currency: str | None = Field("USD", max_length=3)
valid_from: date | None = None
valid_to: date | None = None
default_origin: str | None = Field(None, max_length=20)
status: str = Field("borrador", max_length=20)
notes: str | None = None
class RateSheetCreate(RateSheetBase):
pass
class RateSheetUpdate(BaseModel):
supplier_id: int | None = None
mode: str | None = Field(None, max_length=20)
name: str | None = Field(None, max_length=255)
currency: str | None = Field(None, max_length=3)
valid_from: date | None = None
valid_to: date | None = None
default_origin: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
notes: str | None = None
class RateSheetResponse(RateSheetBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
source_file: str | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
lane_count: int | None = None
# ---------- Importación ----------
class ImportPreviewRow(BaseModel):
row: int
data: dict
ok: bool
warnings: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
class ImportPreview(BaseModel):
mode: str
total: int
valid: int
rows: list[ImportPreviewRow]
columns: list[str]
class ImportConfirm(RateSheetCreate):
lanes: list[RateLaneCreate]
# ---------- Costeo ----------
class CostRequest(BaseModel):
mode: str
origin: str | None = None
destination: str | None = None
on_date: date | None = None
gross_weight_kg: Decimal | None = None
volume_m3: Decimal | None = None
# Dimensiones (cm) para el peso volumétrico aéreo (P/Vol = L×A×H×cant / 6000)
length_cm: Decimal | None = None
width_cm: Decimal | None = None
height_cm: Decimal | None = None
equipment_type: str | None = None
quantity: int = 1
dangerous: bool = False
class CostChargeLine(BaseModel):
concept: str
amount: Decimal
class CostOption(BaseModel):
rate_sheet_id: int
rate_sheet_name: str
supplier_id: int | None
currency: str | None
chargeable: Decimal | None = None # peso/wm facturable usado
base_cost: Decimal
charges: list[CostChargeLine] = Field(default_factory=list)
total_cost: Decimal
transit_days: int | None = None
detail: str | None = None
class CostResult(BaseModel):
request: CostRequest
options: list[CostOption]

View File

@@ -0,0 +1,93 @@
"""Modelos del módulo Tarifario (base de costos para Cotizaciones).
Un ``RateSheet`` (tarifario) pertenece a un proveedor y agrupa muchas
``RateLane`` (rutas origen→destino). Cada ruta tiene, según el modo:
- Aéreo / LCL: varios ``RateBreak`` (quiebres de peso/volumen con su tarifa).
- FCL / terrestre: una tarifa plana por contenedor/unidad (``flat_rate``).
Los ``RateCharge`` son cargos adicionales a nivel tarifario o ruta.
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class RateSheet(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_sheets"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# aereo | maritimo_fcl | maritimo_lcl | terrestre
mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'USD'"))
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True)
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True)
default_origin: Mapped[str | None] = mapped_column(String(20), nullable=True)
# borrador | activo | vencido | reemplazado
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"))
source_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class RateLane(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_lanes"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rate_sheet_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.rate_sheets.id"), nullable=False, index=True
)
origin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
destination: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
region: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Solo FCL/terrestre (código del catálogo tipo_equipo). Nulo en aéreo/LCL.
equipment_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
# per_kg | per_wm | per_container | flat
rate_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
min_charge: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
flat_rate: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
transit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class RateBreak(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_breaks"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rate_lane_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.rate_lanes.id"), nullable=False, index=True
)
# Umbral del quiebre (kg en aéreo; W/M en LCL)
from_qty: Mapped[Decimal] = mapped_column(Numeric(12, 3), nullable=False, server_default=text("0"))
rate: Mapped[Decimal] = mapped_column(Numeric(14, 4), nullable=False, server_default=text("0"))
class RateCharge(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_charges"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rate_sheet_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.rate_sheets.id"), nullable=True, index=True
)
rate_lane_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.rate_lanes.id"), nullable=True, index=True
)
concept: Mapped[str] = mapped_column(String(60), nullable=False)
# fijo | por_kg | por_guia | por_contenedor | porcentaje
charge_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'fijo'"))
value: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
condition: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,269 @@
"""Endpoints del módulo Tarifario."""
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
CostRequest,
CostResult,
ImportPreview,
RateBreakDTO,
RateChargeCreate,
RateChargeResponse,
RateChargeUpdate,
RateLaneCreate,
RateLaneResponse,
RateSheetCreate,
RateSheetResponse,
RateSheetUpdate,
)
router = APIRouter(prefix="/rate-sheets", tags=["Tarifario"])
def _ctx(current_user: dict):
return current_user["tenant_id"], current_user.get("sub") or current_user.get("id")
def _sheet_out(db: Session, tenant_id: int, sheet) -> RateSheetResponse:
out = RateSheetResponse.model_validate(sheet)
out.lane_count = service.lane_count(db, tenant_id, sheet.id)
return out
def _lane_out(db: Session, lane) -> RateLaneResponse:
out = RateLaneResponse.model_validate(lane)
out.breaks = [RateBreakDTO.model_validate(b) for b in service.breaks_of(db, lane.id)]
return out
# ---------------- Tarifarios ----------------
@router.get("", response_model=list[RateSheetResponse])
def list_sheets(
company_id: int = Query(...),
mode: str | None = Query(None),
supplier_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
sheets = service.list_sheets(db, tenant_id, company_id, mode=mode, supplier_id=supplier_id)
return [_sheet_out(db, tenant_id, s) for s in sheets]
@router.post("", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
def create_sheet(
data: RateSheetCreate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, user_id = _ctx(current_user)
sheet = service.create_sheet(db, tenant_id, company_id, data, user_id)
return _sheet_out(db, tenant_id, sheet)
@router.get("/template")
def download_template(
mode: str = Query(..., description="aereo | maritimo_fcl | maritimo_lcl | terrestre"),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
):
content = service.build_template(mode)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="plantilla_tarifario_{mode}.xlsx"'},
)
@router.post("/import/preview", response_model=ImportPreview)
async def import_preview(
company_id: int = Query(...),
mode: str = Form(...),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
):
content = await file.read()
return service.parse_excel(mode, content)
@router.post("/import", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
async def import_sheet(
company_id: int = Query(...),
mode: str = Form(...),
name: str = Form(...),
supplier_id: int | None = Form(None),
currency: str = Form("USD"),
valid_from: date | None = Form(None),
valid_to: date | None = Form(None),
default_origin: str | None = Form(None),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, user_id = _ctx(current_user)
content = await file.read()
header = RateSheetCreate(
mode=mode, name=name, supplier_id=supplier_id, currency=currency,
valid_from=valid_from, valid_to=valid_to, default_origin=default_origin,
)
sheet = service.import_from_excel(db, tenant_id, company_id, mode, content, header, user_id)
return _sheet_out(db, tenant_id, sheet)
@router.get("/{sheet_id}", response_model=RateSheetResponse)
def get_sheet(
sheet_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
return _sheet_out(db, tenant_id, service.get_sheet(db, tenant_id, company_id, sheet_id))
@router.patch("/{sheet_id}", response_model=RateSheetResponse)
def update_sheet(
sheet_id: int,
data: RateSheetUpdate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, user_id = _ctx(current_user)
return _sheet_out(db, tenant_id, service.update_sheet(db, tenant_id, company_id, sheet_id, data, user_id))
@router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sheet(
sheet_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.delete_sheet(db, tenant_id, company_id, sheet_id)
# ---------------- Rutas (lanes) ----------------
@router.get("/{sheet_id}/lanes", response_model=list[RateLaneResponse])
def list_lanes(
sheet_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.get_sheet(db, tenant_id, company_id, sheet_id)
return [_lane_out(db, lane) for lane in service.list_lanes(db, tenant_id, sheet_id)]
@router.post("/{sheet_id}/lanes", response_model=RateLaneResponse, status_code=status.HTTP_201_CREATED)
def create_lane(
sheet_id: int,
data: RateLaneCreate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
lane = service.create_lane(db, tenant_id, company_id, sheet_id, data)
return _lane_out(db, lane)
@router.delete("/{sheet_id}/lanes/{lane_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lane(
sheet_id: int,
lane_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.delete_lane(db, tenant_id, sheet_id, lane_id)
# ---------------- Cargos adicionales ----------------
@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse])
def list_charges(
sheet_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.get_sheet(db, tenant_id, company_id, sheet_id)
return service.list_charges(db, tenant_id, sheet_id)
@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED)
def create_charge(
sheet_id: int,
data: RateChargeCreate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
return service.create_charge(db, tenant_id, company_id, sheet_id, data)
@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse)
def update_charge(
sheet_id: int,
charge_id: int,
data: RateChargeUpdate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
return service.update_charge(db, tenant_id, sheet_id, charge_id, data)
@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_charge(
sheet_id: int,
charge_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
service.delete_charge(db, tenant_id, sheet_id, charge_id)
# ---------------- Motor de costeo ----------------
cost_router = APIRouter(tags=["Tarifario"])
@cost_router.post("/rate-quote", response_model=CostResult)
def rate_quote(
req: CostRequest,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Calcula opciones de costo (por proveedor) para una ruta/carga."""
tenant_id, _ = _ctx(current_user)
options = service.quote_cost(db, tenant_id, company_id, req)
return CostResult(request=req, options=options)
@cost_router.get("/rate-locations")
def rate_locations(
mode: str = Query(...),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador."""
tenant_id, _ = _ctx(current_user)
return service.lane_locations(db, tenant_id, company_id, mode)

View File

@@ -0,0 +1,565 @@
"""Lógica del módulo Tarifario: CRUD, importación por Excel y motor de costeo."""
import io
from datetime import date
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from .dto import (
CostChargeLine,
CostOption,
CostRequest,
ImportConfirm,
ImportPreview,
ImportPreviewRow,
RateLaneCreate,
RateSheetCreate,
RateSheetUpdate,
)
from ..common.pricing import air_volumetric_kg
from .models import RateBreak, RateCharge, RateLane, RateSheet
# Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg).
# Respaldo cuando solo se conoce el volumen en m³ (sin dimensiones cm).
AIR_VOLUMETRIC_FACTOR = Decimal("167")
# ============================================================ CRUD tarifarios
def _sheet_query(db: Session, tenant_id: int, company_id: int):
return db.query(RateSheet).filter(
RateSheet.tenant_id == tenant_id,
RateSheet.company_id == company_id,
RateSheet.deleted_at.is_(None),
)
def list_sheets(db: Session, tenant_id: int, company_id: int, mode: str | None = None,
supplier_id: int | None = None) -> list[RateSheet]:
q = _sheet_query(db, tenant_id, company_id)
if mode:
q = q.filter(RateSheet.mode == mode)
if supplier_id:
q = q.filter(RateSheet.supplier_id == supplier_id)
return q.order_by(RateSheet.created_at.desc()).all()
def lane_count(db: Session, tenant_id: int, sheet_id: int) -> int:
return (
db.query(RateLane)
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
RateLane.deleted_at.is_(None))
.count()
)
def get_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> RateSheet:
sheet = _sheet_query(db, tenant_id, company_id).filter(RateSheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tarifario no encontrado")
return sheet
def create_sheet(db: Session, tenant_id: int, company_id: int, data: RateSheetCreate,
user_id: str | None) -> RateSheet:
sheet = RateSheet(
tenant_id=tenant_id, company_id=company_id,
**data.model_dump(),
created_by=user_id, updated_by=user_id,
)
db.add(sheet)
db.commit()
db.refresh(sheet)
return sheet
def update_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int,
data: RateSheetUpdate, user_id: str | None) -> RateSheet:
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(sheet, field, value)
sheet.updated_by = user_id
db.commit()
db.refresh(sheet)
return sheet
def delete_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> None:
from sqlalchemy import func
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
sheet.deleted_at = func.now()
db.commit()
# ============================================================ Rutas (lanes)
def list_lanes(db: Session, tenant_id: int, sheet_id: int) -> list[RateLane]:
return (
db.query(RateLane)
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
RateLane.deleted_at.is_(None))
.order_by(RateLane.region, RateLane.destination)
.all()
)
def breaks_of(db: Session, lane_id: int) -> list[RateBreak]:
return (
db.query(RateBreak)
.filter(RateBreak.rate_lane_id == lane_id, RateBreak.deleted_at.is_(None))
.order_by(RateBreak.from_qty)
.all()
)
def _add_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
lane_data: RateLaneCreate) -> RateLane:
payload = lane_data.model_dump(exclude={"breaks"})
lane = RateLane(tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, **payload)
db.add(lane)
db.flush() # id
for br in lane_data.breaks:
db.add(RateBreak(
tenant_id=tenant_id, company_id=company_id, rate_lane_id=lane.id,
from_qty=br.from_qty, rate=br.rate,
))
return lane
def create_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
lane_data: RateLaneCreate) -> RateLane:
get_sheet(db, tenant_id, company_id, sheet_id) # valida pertenencia
lane = _add_lane(db, tenant_id, company_id, sheet_id, lane_data)
db.commit()
db.refresh(lane)
return lane
def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> None:
from sqlalchemy import func
lane = (
db.query(RateLane)
.filter(RateLane.id == lane_id, RateLane.rate_sheet_id == sheet_id,
RateLane.tenant_id == tenant_id)
.first()
)
if not lane:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ruta no encontrada")
lane.deleted_at = func.now()
db.commit()
# ============================================================ Cargos adicionales
def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]:
return (
db.query(RateCharge)
.filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id,
RateCharge.deleted_at.is_(None))
.order_by(RateCharge.concept)
.all()
)
def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge:
get_sheet(db, tenant_id, company_id, sheet_id)
ch = RateCharge(
tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id,
rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type,
value=data.value, condition=data.condition,
)
db.add(ch)
db.commit()
db.refresh(ch)
return ch
def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge:
ch = (
db.query(RateCharge)
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
RateCharge.tenant_id == tenant_id)
.first()
)
if not ch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(ch, field, value)
db.commit()
db.refresh(ch)
return ch
def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None:
from sqlalchemy import func
ch = (
db.query(RateCharge)
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
RateCharge.tenant_id == tenant_id)
.first()
)
if not ch:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
ch.deleted_at = func.now()
db.commit()
# ============================================================ Importación Excel
# Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre).
TEMPLATES: dict[str, list[str]] = {
"aereo": ["Region", "Origen", "Destino", "IATA", "Min", "100", "300", "500", "1000"],
"maritimo_fcl": ["Origen", "Destino", "Tipo contenedor", "Tarifa", "Transito", "Notas"],
"maritimo_lcl": ["Origen", "Destino", "Tarifa W/M", "Minimo", "Notas"],
"terrestre": ["Origen", "Destino", "Tarifa", "Transito", "Notas"],
}
def build_template(mode: str) -> bytes:
"""Genera un .xlsx con los encabezados del modo + una fila de ejemplo."""
import openpyxl
if mode not in TEMPLATES:
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = mode
headers = TEMPLATES[mode]
ws.append(headers)
examples = {
"aereo": ["EUROPA", "NLU", "Frankfurt", "FRA", 190, 1.00, 1.00, 0.95, 0.90],
"maritimo_fcl": ["MXZLO", "CNSHA", "40HC", 2500, 28, "THC no incluido"],
"maritimo_lcl": ["MXZLO", "USLAX", 45, 80, "1 W/M = 1 ton o 1 m3"],
"terrestre": ["Monterrey", "Laredo", 850, 1, ""],
}
ws.append(examples[mode])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _num(v: Any) -> Decimal | None:
if v is None or v == "":
return None
try:
return Decimal(str(v).replace("$", "").replace(",", "").strip())
except Exception:
return None
def parse_excel(mode: str, content: bytes) -> ImportPreview:
"""Lee el Excel y devuelve una vista previa con validaciones (no persiste)."""
import openpyxl
if mode not in TEMPLATES:
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
try:
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True, read_only=True)
except Exception:
raise HTTPException(status_code=400, detail="No se pudo leer el archivo Excel")
ws = wb.active
rows_iter = ws.iter_rows(values_only=True)
header = next(rows_iter, None)
if not header:
raise HTTPException(status_code=400, detail="El archivo está vacío")
cols = [str(c).strip() if c is not None else "" for c in header]
idx = {name.lower(): i for i, name in enumerate(cols)}
def cell(row, name):
i = idx.get(name.lower())
return row[i] if i is not None and i < len(row) else None
preview_rows: list[ImportPreviewRow] = []
valid = 0
for n, row in enumerate(rows_iter, start=2):
if row is None or all(c is None or str(c).strip() == "" for c in row):
continue
errors: list[str] = []
warnings: list[str] = []
data: dict = {}
if mode == "aereo":
data = {
"region": cell(row, "Region"),
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino") or cell(row, "IATA"),
"iata": cell(row, "IATA"),
"min_charge": _num(cell(row, "Min")),
"breaks": {b: _num(cell(row, b)) for b in ("100", "300", "500", "1000")},
}
if not data["destination"]:
errors.append("Falta destino/IATA")
if not any(v is not None for v in data["breaks"].values()):
errors.append("Sin tarifas por quiebre")
elif mode == "maritimo_fcl":
data = {
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino"),
"equipment_type": cell(row, "Tipo contenedor"),
"flat_rate": _num(cell(row, "Tarifa")),
"transit_days": _num(cell(row, "Transito")),
"notes": cell(row, "Notas"),
}
if data["flat_rate"] is None:
errors.append("Falta la tarifa")
if not data["equipment_type"]:
warnings.append("Sin tipo de contenedor")
elif mode == "maritimo_lcl":
data = {
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino"),
"wm_rate": _num(cell(row, "Tarifa W/M")),
"min_charge": _num(cell(row, "Minimo")),
"notes": cell(row, "Notas"),
}
if data["wm_rate"] is None:
errors.append("Falta la tarifa W/M")
else: # terrestre
data = {
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino"),
"flat_rate": _num(cell(row, "Tarifa")),
"transit_days": _num(cell(row, "Transito")),
"notes": cell(row, "Notas"),
}
if data["flat_rate"] is None:
errors.append("Falta la tarifa")
if not data.get("destination"):
errors.append("Falta destino")
ok = not errors
if ok:
valid += 1
preview_rows.append(ImportPreviewRow(row=n, data=_jsonable(data), ok=ok,
warnings=warnings, errors=errors))
return ImportPreview(mode=mode, total=len(preview_rows), valid=valid,
rows=preview_rows, columns=cols)
def _jsonable(d: dict) -> dict:
out = {}
for k, v in d.items():
if isinstance(v, Decimal):
out[k] = float(v)
elif isinstance(v, dict):
out[k] = {kk: (float(vv) if isinstance(vv, Decimal) else vv) for kk, vv in v.items()}
else:
out[k] = v
return out
def _rows_to_lanes(mode: str, rows: list[ImportPreviewRow], default_origin: str | None) -> list[RateLaneCreate]:
lanes: list[RateLaneCreate] = []
for r in rows:
if not r.ok:
continue
d = r.data
origin = d.get("origin") or default_origin
if mode == "aereo":
breaks = [
{"from_qty": Decimal(b), "rate": Decimal(str(v))}
for b, v in (d.get("breaks") or {}).items() if v is not None
]
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None,
destination=str(d.get("destination")),
region=d.get("region"), rate_unit="per_kg",
min_charge=_num(d.get("min_charge")),
breaks=breaks, # type: ignore[arg-type]
))
elif mode == "maritimo_fcl":
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None, destination=str(d.get("destination")),
equipment_type=d.get("equipment_type"), rate_unit="per_container",
flat_rate=_num(d.get("flat_rate")),
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
notes=d.get("notes"),
))
elif mode == "maritimo_lcl":
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None, destination=str(d.get("destination")),
rate_unit="per_wm", min_charge=_num(d.get("min_charge")),
breaks=[{"from_qty": Decimal(0), "rate": Decimal(str(d["wm_rate"]))}], # type: ignore[arg-type]
notes=d.get("notes"),
))
else:
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None, destination=str(d.get("destination")),
rate_unit="flat", flat_rate=_num(d.get("flat_rate")),
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
notes=d.get("notes"),
))
return lanes
def confirm_import(db: Session, tenant_id: int, company_id: int, data: ImportConfirm,
user_id: str | None) -> RateSheet:
"""Crea el tarifario + rutas a partir de la vista previa confirmada."""
sheet = RateSheet(
tenant_id=tenant_id, company_id=company_id,
supplier_id=data.supplier_id, mode=data.mode, name=data.name,
currency=data.currency, valid_from=data.valid_from, valid_to=data.valid_to,
default_origin=data.default_origin, status=data.status or "borrador",
notes=data.notes, created_by=user_id, updated_by=user_id,
)
db.add(sheet)
db.flush()
for lane in data.lanes:
_add_lane(db, tenant_id, company_id, sheet.id, lane)
db.commit()
db.refresh(sheet)
return sheet
def import_from_excel(db: Session, tenant_id: int, company_id: int, mode: str,
content: bytes, header: RateSheetCreate, user_id: str | None) -> RateSheet:
"""Atajo: parsea el Excel y crea el tarifario en un solo paso."""
preview = parse_excel(mode, content)
lanes = _rows_to_lanes(mode, preview.rows, header.default_origin)
return confirm_import(
db, tenant_id, company_id,
ImportConfirm(**header.model_dump(), lanes=lanes), user_id,
)
# ============================================================ Motor de costeo
def _volumetric_kg(volume_m3: Decimal | None) -> Decimal:
return (volume_m3 or Decimal(0)) * AIR_VOLUMETRIC_FACTOR
def _rate_for(breaks: list[RateBreak], qty: Decimal) -> Decimal | None:
"""Tarifa aplicable al peso/wm 'qty' (mayor quiebre cuyo umbral <= qty)."""
if not breaks:
return None
applicable = None
for b in breaks:
if b.from_qty <= qty:
applicable = b.rate
if applicable is None:
applicable = breaks[0].rate # por debajo del primer quiebre → tarifa base (gobierna el mínimo)
return applicable
def _best_break_cost(breaks: list[RateBreak], qty: Decimal) -> Decimal:
"""Costo base con optimización de quiebre (declarar peso mayor si conviene)."""
base_rate = _rate_for(breaks, qty)
base = (qty * base_rate) if base_rate is not None else Decimal(0)
for b in breaks:
if b.from_qty > qty:
candidate = b.from_qty * b.rate
if candidate < base:
base = candidate
return base
def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
chargeable: Decimal, quantity: int, dangerous: bool) -> list[CostChargeLine]:
charges = (
db.query(RateCharge)
.filter(
RateCharge.deleted_at.is_(None),
or_(RateCharge.rate_sheet_id == sheet.id, RateCharge.rate_lane_id == lane.id),
)
.all()
)
lines: list[CostChargeLine] = []
for c in charges:
if c.concept == "dgr" and not dangerous:
continue
v = c.value or Decimal(0)
if c.charge_type == "fijo" or c.charge_type == "por_guia":
amt = v
elif c.charge_type == "por_kg":
amt = v * chargeable
elif c.charge_type == "por_contenedor":
amt = v * quantity
elif c.charge_type == "porcentaje":
amt = base * v / Decimal(100)
else:
amt = v
lines.append(CostChargeLine(concept=c.concept, amount=amt))
return lines
def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]:
"""Orígenes/destinos existentes en los tarifarios activos de un modo.
Alinea el cotizador con las rutas realmente cotizables (los códigos provienen
de las lanes, por lo que el costeo siempre encontrará ruta).
"""
sheets = _sheet_query(db, tenant_id, company_id).filter(
RateSheet.mode == mode, RateSheet.status == "activo",
).all()
origins: set[str] = set()
destinations: set[str] = set()
for sheet in sheets:
lanes = db.query(RateLane).filter(
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
).all()
for lane in lanes:
origin = lane.origin or sheet.default_origin
if origin:
origins.add(origin)
if lane.destination:
destinations.add(lane.destination)
return {"origins": sorted(origins), "destinations": sorted(destinations)}
def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]:
on_date = req.on_date or date.today()
sheets = _sheet_query(db, tenant_id, company_id).filter(
RateSheet.mode == req.mode,
RateSheet.status == "activo",
or_(RateSheet.valid_from.is_(None), RateSheet.valid_from <= on_date),
or_(RateSheet.valid_to.is_(None), RateSheet.valid_to >= on_date),
).all()
gross = req.gross_weight_kg or Decimal(0)
options: list[CostOption] = []
for sheet in sheets:
lanes_q = db.query(RateLane).filter(
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
)
if req.destination:
lanes_q = lanes_q.filter(RateLane.destination == req.destination)
for lane in lanes_q.all():
# Origen: match exacto o el default del tarifario.
lane_origin = lane.origin or sheet.default_origin
if req.origin and lane_origin and lane_origin != req.origin:
continue
if req.mode == "maritimo_fcl":
if req.equipment_type and lane.equipment_type and lane.equipment_type != req.equipment_type:
continue
chargeable = Decimal(req.quantity)
base = (lane.flat_rate or Decimal(0)) * req.quantity
detail = f"{req.quantity} x {lane.equipment_type or 'contenedor'}"
elif req.mode == "terrestre":
chargeable = Decimal(req.quantity)
base = (lane.flat_rate or Decimal(0)) * req.quantity
detail = "tarifa por ruta"
elif req.mode == "maritimo_lcl":
tons = gross / Decimal(1000)
wm = max(tons, req.volume_m3 or Decimal(0))
brks = breaks_of(db, lane.id)
base = _best_break_cost(brks, wm) if brks else Decimal(0)
chargeable = wm
base = max(base, lane.min_charge or Decimal(0))
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
else: # aereo
# P/Vol por dimensiones (L×A×H×cant / 6000); si no hay dimensiones,
# respaldo con el volumen en m³ × 167.
vol_by_dims = air_volumetric_kg(req.length_cm, req.width_cm, req.height_cm, req.quantity)
volumetric = vol_by_dims if vol_by_dims > 0 else _volumetric_kg(req.volume_m3)
chargeable = max(gross, volumetric)
brks = breaks_of(db, lane.id)
base = _best_break_cost(brks, chargeable)
base = max(base, lane.min_charge or Decimal(0))
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)"
charge_lines = _apply_charges(db, sheet, lane, base, chargeable, req.quantity, req.dangerous)
total = base + sum((c.amount for c in charge_lines), Decimal(0))
options.append(CostOption(
rate_sheet_id=sheet.id, rate_sheet_name=sheet.name, supplier_id=sheet.supplier_id,
currency=sheet.currency, chargeable=chargeable, base_cost=base,
charges=charge_lines, total_cost=total, transit_days=lane.transit_days, detail=detail,
))
options.sort(key=lambda o: o.total_cost)
return options

View File

@@ -13,16 +13,17 @@ from . import permissions # noqa: F401 (side-effect: registra permisos del CRM
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .cases.routes import router as cases_router
from .catalogs.routes import router as catalogs_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
from .expediente_gateway.routes import router as expediente_gateway_router
from .expedientes.routes import router as expedientes_router
from .leads.routes import router as leads_router
from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_router
from .quotes.routes import router as quotes_router
from .rates.routes import cost_router as rates_cost_router
from .rates.routes import router as rates_router
from .service_requests.routes import router as service_requests_router
from .suppliers.routes import router as suppliers_router
from .uploads.routes import router as uploads_router
@@ -37,14 +38,15 @@ router.include_router(suppliers_router)
router.include_router(contacts_router)
router.include_router(addresses_router)
router.include_router(documents_router)
router.include_router(expedientes_router)
router.include_router(expediente_gateway_router)
router.include_router(service_requests_router)
router.include_router(quotes_router)
router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(cases_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)
router.include_router(rates_router)
router.include_router(rates_cost_router)

View File

@@ -7,22 +7,66 @@ from pydantic import BaseModel, ConfigDict, Field
class ServiceRequestBase(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
contact_id: int | None = None
opportunity_id: int | None = None
operation_type: str = Field(..., max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
# Ruta legada (texto libre) — se conserva por compatibilidad
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
# Ruta estructurada (país por catálogo ISO; ciudad/puerto por catálogo o texto)
origin_country: str | None = Field(None, max_length=3)
origin_city: str | None = Field(None, max_length=120)
origin_port: str | None = Field(None, max_length=20)
destination_country: str | None = Field(None, max_length=3)
destination_city: str | None = Field(None, max_length=120)
destination_port: str | None = Field(None, max_length=20)
pickup_location: str | None = Field(None, max_length=255)
delivery_location: str | None = Field(None, max_length=255)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # peso bruto
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
load_type: str | None = Field(None, max_length=10) # FCL | LCL | AMBAS
container_equipment: str | None = Field(None, max_length=120)
container_count: int | None = Field(None, ge=0)
commodity: str | None = None
required_date: date | None = None
request_date: date | None = None
estimated_shipment_date: date | None = None
currency: str | None = Field(None, max_length=3)
priority: str | None = Field(None, max_length=20)
# Mercancía
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
insurance_required: bool = False
hs_code: str | None = Field(None, max_length=20)
goods_origin_country: str | None = Field(None, max_length=3)
hazardous_imo: bool = False
refrigerated: bool = False
stackable: bool = False
# Dimensiones y bultos
pieces_count: int | None = Field(None, ge=0)
boxes_count: int | None = Field(None, ge=0)
pallets_count: int | None = Field(None, ge=0)
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
measurement_unit: str | None = Field(None, max_length=20)
# LCL
packaging_type: str | None = Field(None, max_length=20)
oversized: bool = False
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
# Servicios adicionales (códigos del catálogo servicio_adicional) y pago
additional_services: list[str] | None = None
additional_service_costs: dict[str, float] | None = None # {codigo: costo estimado}
payment_method: str | None = Field(None, max_length=20)
destination_agent_id: int | None = None
requirements: str | None = None
client_notes: str | None = None
internal_notes: str | None = None
status: str = Field("nueva", max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
@@ -38,8 +82,12 @@ class ServiceRequestContactInput(BaseModel):
class ServiceRequestFromOpportunityInput(BaseModel):
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02)."""
operation_type: str = Field(..., max_length=20) # importacion | exportacion
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02).
La dirección impo/expo se hereda de la oportunidad; ``operation_type`` aquí es
solo un respaldo para oportunidades antiguas que no la tengan capturada.
"""
operation_type: str | None = Field(None, max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
@@ -51,6 +99,7 @@ class ServiceRequestFromOpportunityInput(BaseModel):
class ServiceRequestUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
contact_id: int | None = None
opportunity_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
@@ -58,15 +107,52 @@ class ServiceRequestUpdate(BaseModel):
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
origin_country: str | None = Field(None, max_length=3)
origin_city: str | None = Field(None, max_length=120)
origin_port: str | None = Field(None, max_length=20)
destination_country: str | None = Field(None, max_length=3)
destination_city: str | None = Field(None, max_length=120)
destination_port: str | None = Field(None, max_length=20)
pickup_location: str | None = Field(None, max_length=255)
delivery_location: str | None = Field(None, max_length=255)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
container_equipment: str | None = Field(None, max_length=120)
container_count: int | None = Field(None, ge=0)
commodity: str | None = None
required_date: date | None = None
request_date: date | None = None
estimated_shipment_date: date | None = None
currency: str | None = Field(None, max_length=3)
priority: str | None = Field(None, max_length=20)
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
insurance_required: bool | None = None
hs_code: str | None = Field(None, max_length=20)
goods_origin_country: str | None = Field(None, max_length=3)
hazardous_imo: bool | None = None
refrigerated: bool | None = None
stackable: bool | None = None
pieces_count: int | None = Field(None, ge=0)
boxes_count: int | None = Field(None, ge=0)
pallets_count: int | None = Field(None, ge=0)
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
measurement_unit: str | None = Field(None, max_length=20)
packaging_type: str | None = Field(None, max_length=20)
oversized: bool | None = None
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
additional_services: list[str] | None = None
additional_service_costs: dict[str, float] | None = None
payment_method: str | None = Field(None, max_length=20)
destination_agent_id: int | None = None
requirements: str | None = None
client_notes: str | None = None
internal_notes: str | None = None
status: str | None = Field(None, max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
@@ -76,6 +162,7 @@ class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -19,6 +19,7 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
@@ -57,6 +58,57 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ----- Campos del documento maestro de cotización (T2026-08) -----
# Datos generales
contact_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
)
request_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha de la solicitud
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
priority: Mapped[str | None] = mapped_column(String(20), nullable=True) # baja|normal|alta|urgente
# Ruta (país por catálogo ISO; ciudad/puerto por catálogo o texto libre)
origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
origin_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
origin_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
destination_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
destination_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
destination_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
pickup_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
delivery_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
estimated_shipment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Mercancía
cargo_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
insurance_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
hs_code: Mapped[str | None] = mapped_column(String(20), nullable=True) # fracción arancelaria
goods_origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) # país de origen de la mercancía
hazardous_imo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
refrigerated: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
stackable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
# Dimensiones y bultos
pieces_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
boxes_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
pallets_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
net_weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # peso neto (weight = bruto)
length_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
width_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
height_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
measurement_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
# FCL
container_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# LCL
packaging_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
oversized: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
weight_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
# Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago
additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Costo estimado por servicio adicional marcado: {codigo: costo}
additional_service_costs: Mapped[dict | None] = mapped_column(JSON, nullable=True)
payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Notas
client_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class RateRequest(Base, TenantScopedMixin, TimestampMixin):
"""Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6)."""

View File

@@ -4,8 +4,10 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..catalogs.data import INCOTERM_CODES
from ..expedientes import service as expedientes_service
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..opportunities.models import Opportunity
from ..suppliers.models import Supplier
from .dto import (
@@ -38,6 +40,8 @@ def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int
def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
if not _exists(db, Account, data.get("account_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
if not _exists(db, Contact, data.get("contact_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El contacto asociado no existe")
if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe")
if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id):
@@ -50,24 +54,6 @@ def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id:
)
def _ensure_expediente(
db: Session, obj: ServiceRequest, tenant_id: int, company_id: int, user_id: str | None
) -> None:
"""Le da su expediente —y con él su folio— a una solicitud recién creada.
Va DENTRO de la transacción del alta, no como un paso posterior best-effort: el folio del
expediente es lo que el usuario ve en la pantalla en cuanto guarda, así que una solicitud sin
expediente sería una solicitud a medias. Si esto falla, el alta entera se revierte y el usuario
ve el error, que es preferible a una solicitud que nadie puede documentar.
Lo best-effort es la *réplica hacia EFC*, no esto: aquélla vive en el gateway y nunca rompe la
operación local.
"""
expedientes_service.ensure_expediente_for_service_request(
db, obj.id, tenant_id, company_id, user_id, account_id=obj.account_id
)
# ----- Service requests (RFQ) -----
def get_service_requests(
@@ -122,12 +108,16 @@ def create_service_request(
data = payload.model_dump()
_validate_request_refs(db, data, tenant_id, company_id)
obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Folio S... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type)
# Expediente: normalmente nace en la oportunidad; si la solicitud es directa, se mintea aquí
if not obj.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=obj.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
)
obj.case_id = case.id
db.add(obj)
# flush y no commit: el expediente necesita el id de la solicitud, pero los dos tienen que
# nacer en la MISMA transacción. Si el expediente se creara aparte y el alta fallara después,
# quedaría un folio quemado colgando de una solicitud que no existe.
db.flush()
_ensure_expediente(db, obj, tenant_id, company_id, user_id)
db.commit()
db.refresh(obj)
return obj
@@ -190,10 +180,24 @@ def create_from_opportunity(
)
if not opp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
# Idempotente: si la oportunidad ya se convirtió, devuelve la misma solicitud
if opp.converted_service_request_id:
existing = get_service_request(db, opp.converted_service_request_id, tenant_id, company_id)
return existing
# La dirección impo/expo se hereda de la oportunidad (respaldo: el payload)
operation_type = opp.operation_type or payload.operation_type
if not operation_type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define la dirección (importación/exportación) en la oportunidad para convertirla",
)
obj = ServiceRequest(
account_id=opp.account_id,
contact_id=opp.contact_id,
opportunity_id=opp.id,
operation_type=payload.operation_type,
operation_type=operation_type,
transport_mode=payload.transport_mode,
service_type=payload.service_type,
incoterm=payload.incoterm,
@@ -202,6 +206,8 @@ def create_from_opportunity(
status="nueva",
notes=payload.notes,
owner_user_id=opp.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "S", operation_type),
case_id=opp.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
@@ -209,7 +215,15 @@ def create_from_opportunity(
)
db.add(obj)
db.flush()
_ensure_expediente(db, obj, tenant_id, company_id, user_id)
# Expediente heredado de la oportunidad (fallback si la oportunidad es antigua sin expediente)
if not obj.case_id:
obj.case_id = cases_service.create_case(
db, tenant_id, company_id, account_id=opp.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
).id
opp.case_id = obj.case_id
cases_service.advance_stage(db, obj.case_id, "solicitud")
# Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia)
opp.converted_service_request_id = obj.id
db.commit()
db.refresh(obj)
return obj

View File

@@ -13,6 +13,7 @@ class SupplierBase(BaseModel):
person_type: str | None = Field(None, max_length=10)
status: str = Field("active", max_length=20)
classifications: list[str] = Field(default_factory=list)
classification_other: str | None = Field(None, max_length=120)
# Comercial
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
@@ -52,6 +53,7 @@ class SupplierUpdate(BaseModel):
person_type: str | None = Field(None, max_length=10)
status: str | None = Field(None, max_length=20)
classifications: list[str] | None = None
classification_other: str | None = Field(None, max_length=120)
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
countries: list[str] | None = None

View File

@@ -30,6 +30,8 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin):
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
# Texto libre cuando la clasificación incluye "otro"
classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
# ----- Información comercial -----
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -7,31 +7,16 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
import re
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
router = APIRouter()
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
# Trozo de lectura. No es crítico afinarlo: lo que importa es que la lectura sea POR PARTES y no de
# golpe, para poder abortar en cuanto se pase del tope.
_CHUNK_BYTES = 1 * 1024 * 1024
# Allowlist de extensiones, igual que la que ya tienen el avatar y el centro de ayuda. Coincide con
# la que EFC aplica del otro lado del carril del expediente: si aquí se aceptara algo que allá se
# rechaza, el archivo se guardaría y su entrega quedaría condenada a `failed`.
ALLOWED_UPLOAD_EXTENSIONS = (
".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip", ".docx", ".xlsx",
)
# Prefijos que este endpoint puede firmar. Es un subárbol EXPLÍCITO, no toda la company: ver la
# nota de `get_upload_url`.
_PREFIJOS_FIRMABLES = ("crm-docs/", "expedientes/")
def _safe_filename(name: str | None) -> str:
base = (name or "archivo").strip().replace(" ", "_")
@@ -39,44 +24,6 @@ def _safe_filename(name: str | None) -> str:
return base[:120]
def extension_de(name: str | None) -> str:
base = (name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
if "." not in base:
return ""
return "." + base.rsplit(".", 1)[1].lower()
def validar_extension(name: str | None) -> None:
if extension_de(name) not in ALLOWED_UPLOAD_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Ese tipo de archivo no está permitido.",
)
async def leer_acotado(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
"""Lee el archivo POR PARTES y aborta en cuanto pasa del tope.
``await file.read()`` a secas trae el archivo entero a memoria **antes** de que nadie pueda
mirar su tamaño: un archivo de 2 GB se bufferiza completo solo para responder 422 después. Aquí
el corte ocurre al superar el tope, así que el peor caso en RAM es el tope más un trozo.
"""
partes: list[bytes] = []
total = 0
while True:
chunk = await file.read(_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El archivo excede el tamaño máximo permitido.",
)
partes.append(chunk)
return b"".join(partes)
@router.post("/uploads")
async def upload_file(
file: UploadFile = File(...),
@@ -84,8 +31,12 @@ async def upload_file(
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user["tenant_id"]
validar_extension(file.filename)
content = await leer_acotado(file)
content = await file.read()
if len(content) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El archivo excede el tamaño máximo permitido (25 MB)",
)
filename = _safe_filename(file.filename)
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}"
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
@@ -109,13 +60,30 @@ def get_upload_url(
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
# Y SOLO dentro de los subárboles de documentos. Validar únicamente el prefijo de la company
# permitía firmar una URL para CUALQUIER objeto suyo —`certificates/` (llaves privadas de la
# FIEL), `fin-invoices/`, `imports/csv/`— con solo el permiso `crm.access`. El alcance de este
# endpoint es "los archivos que el CRM subió", no "todo el almacén de la company".
resto = key[len(prefix):]
if not resto.startswith(_PREFIJOS_FIRMABLES):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
return {"url": presigned_get_url(key)}
@router.get("/uploads/download")
def download_file(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
"""
tenant_id = current_user["tenant_id"]
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
try:
data = get_object_bytes(key)
except Exception:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
filename = key.rsplit("/", 1)[-1]
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View File

@@ -100,6 +100,7 @@ class InvoiceResponse(InvoiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
status: str
subtotal: Decimal
tax_amount: Decimal

View File

@@ -15,6 +15,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
shipment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
)

View File

@@ -6,6 +6,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment
@@ -95,6 +97,15 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Folio F... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False)
# Expediente heredado del embarque (si la factura se genera de uno)
if obj.shipment_id and not obj.case_id:
sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first()
if sh:
obj.case_id = sh.case_id
cases_service.advance_stage(db, obj.case_id, "facturacion")
db.add(obj)
db.flush()
_recompute(db, obj)
@@ -281,6 +292,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
invoice = Invoice(
reference=shipment.reference,
case_id=shipment.case_id,
shipment_id=shipment.id,
quote_id=shipment.quote_id,
account_id=shipment.account_id,
@@ -294,6 +306,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
)
db.add(invoice)
db.flush()
cases_service.advance_stage(db, shipment.case_id, "facturacion")
if quote:
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()

View File

@@ -82,6 +82,7 @@ class ShipmentResponse(ShipmentBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
closed_at: datetime | None = None
closed_by: str | None = None
created_by: str | None = None

View File

@@ -3,7 +3,7 @@ from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
@@ -15,6 +15,7 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
)
@@ -92,12 +93,8 @@ class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.).
Comparte con ``crm.documents`` las columnas del espejo en EFC vía ``EfcDocumentRefMixin``: las
dos tablas alimentan el mismo expediente electrónico y la UI las pinta con el mismo badge.
"""
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
__tablename__ = "shipment_documents"
__table_args__ = {"schema": "ops"}

View File

@@ -65,11 +65,14 @@ def create_shipment(
def create_shipment_from_quote(
quote_id: int = Query(..., description="Cotización aceptada a liberar"),
company_id: int = Query(..., description="Company ID"),
operation_type: str | None = Query(None, description="Confirma la dirección: importacion | exportacion"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user))
return service.create_shipment_from_quote(
db, quote_id, tenant_id, company_id, _user_id(current_user), operation_type=operation_type
)
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)

View File

@@ -5,10 +5,15 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
# Direcciones válidas de la operación (para validar y sembrar hitos).
_OPERATION_TYPES = ("importacion", "exportacion")
from .dto import (
ShipmentCloseInput,
ShipmentCreate,
@@ -173,9 +178,20 @@ def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: i
def create_shipment_from_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None,
operation_type: str | None = None,
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.
La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se
envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio
``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3).
"""
if operation_type is not None and operation_type not in _OPERATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'",
)
quote = (
db.query(Quote)
.filter(
@@ -198,12 +214,16 @@ def create_shipment_from_quote(
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
# La dirección enviada al liberar manda; si no viene, se hereda de la solicitud
resolved = operation_type or (sr.operation_type if sr else None)
shipment = Shipment(
reference=quote.reference,
reference=next_folio(db, tenant_id, company_id, "OP", resolved),
case_id=quote.case_id,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
operation_type=sr.operation_type if sr else None,
operation_type=resolved,
transport_mode=sr.transport_mode if sr else None,
service_type=sr.service_type if sr else None,
incoterm=sr.incoterm if sr else None,
@@ -220,6 +240,14 @@ def create_shipment_from_quote(
db.add(shipment)
if sr:
sr.status = "liberada"
cases_service.advance_stage(db, quote.case_id, "operacion")
db.flush()
# Siembra automática de hitos si ya se conoce la dirección de la operación
for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])):
db.add(ShipmentEvent(
shipment_id=shipment.id, event_type=event_type, title=title, kind=kind,
status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id,
))
db.commit()
db.refresh(shipment)
return shipment

View File

@@ -96,7 +96,6 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_):
celery_app.conf.update(
include=[
"api.v1.modules.core.help_center.tasks",
"api.v1.modules.crm.expediente_gateway.tasks",
# Agrega aquí las tareas de tu proyecto:
# "api.v1.modules.example.tasks",
]
@@ -121,21 +120,6 @@ celery_app.conf.beat_schedule = {
"task": "cleanup_orphan_layout_imports",
"schedule": 3600.0,
},
# Carril CRM -> EFC. Los tres intervalos vienen del carril de referencia de Anexo22: 120 s para
# las dos colas y 300 s para la reconciliacion. El reintento NO es exponencial a proposito —el
# backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo.
"efc-sweep-outbox-every-2-min": {
"task": "expediente_gateway.sweep_outbox",
"schedule": 120.0,
},
"efc-sweep-file-outbox-every-2-min": {
"task": "expediente_gateway.sweep_file_outbox",
"schedule": 120.0,
},
"efc-sweep-expediente-gaps-every-5-min": {
"task": "expediente_gateway.sweep_expediente_gaps",
"schedule": 300.0,
},
}
if __name__ == "__main__":

View File

@@ -42,6 +42,19 @@ class Settings(BaseSettings):
PERMISSION_CACHE_ENABLED: bool = True
PERMISSION_CACHE_TTL_SECONDS: int = 300
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
#
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
SESSION_STORE_ENABLED: bool = False
SESSION_IDLE_MINUTES: int = 30
SESSION_MAX_HOURS: int = 10
# Synchronization
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
@@ -62,7 +75,7 @@ class Settings(BaseSettings):
# URL pública del frontend — usada en links de email (invitaciones, etc.)
APP_PUBLIC_URL: str = "http://localhost:3000"
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", "EFC_API_URL", mode="before")
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
@classmethod
def strip_quotes(cls, v: str) -> str:
if v and isinstance(v, str):
@@ -103,25 +116,6 @@ class Settings(BaseSettings):
S3_FILE_STORAGE: bool = True
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
# ── EFC (expediente electrónico) ────────────────────────────────────────────────────────
# Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC.
# Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC: inventar
# otros obligaría a quien opera los dos sistemas a recordar dos juegos de variables para
# exactamente lo mismo.
# EFC_API_URL vacía = integración APAGADA. Todo el enganche es best-effort y hace no-op:
# el CRM sigue funcionando igual, guardando los archivos solo en su MinIO.
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
# Las subidas van aparte: 8 s no alcanzan para un archivo de 25 MB. Debe quedar POR DEBAJO
# del proxy_read_timeout del nginx de EFC (ver core/efc_client.py).
EFC_UPLOAD_TIMEOUT_MS: int = 55000
# Scaffolding de mTLS: activarlo es configuración, no código.
EFC_MTLS_CA_PATH: str = ""
EFC_MTLS_CERT_PATH: str = ""
EFC_MTLS_KEY_PATH: str = ""
model_config = SettingsConfigDict(
env_file=[".env", "../.env"],
case_sensitive=True,

View File

@@ -1,301 +0,0 @@
"""Cliente HTTP hacia EFC (carril de integración CRM Agentes de Carga -> EFC).
Clon del cliente del gateway de Anexo22, que es el carril de referencia ya en producción
(``anexo22/backend/api/v1/modules/pedimentos/pedimento_gateway/client.py``), con las rutas
cambiadas a ``.../integrations/crm/...``. No es una reinterpretación: el molde de reintentos, el
corte en 4xx y la forma del error se conservan tal cual.
**Síncrono a propósito** (``httpx.Client``): el consumidor es el worker de Celery que drena el
outbox, que corre en contexto sync. El único punto async del carril es el proxy de descarga de cara
al usuario, y ése no usa este cliente.
Todos los endpoints se autentican con el header ``X-Api-Key``
(``settings.EFC_API_KEY`` == ``CRM_INTEGRATION_API_KEY`` del lado de EFC).
"""
import logging
import time
from typing import Any, Optional
import httpx
from core.config import settings
logger = logging.getLogger(__name__)
# Rutas de EFC (prefijo /api/v1/, ver config/urls.py del backend de EFC).
_PATH_ORG_BUSCAR = "/api/v1/organization/integrations/crm/organizaciones/"
_PATH_ORG_RESOLVER = "/api/v1/organization/integrations/crm/organizaciones/resolver/"
_PATH_EXPEDIENTE = "/api/v1/customs/integrations/crm/expedientes/"
_PATH_EXPEDIENTE_COMPLETAR = "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/"
_PATH_EXPEDIENTE_DETALLE = "/api/v1/customs/integrations/crm/expedientes/{folio}/"
_PATH_DOCS = "/api/v1/record/integrations/crm/documentos/"
_PATH_DOCS_LIST = "/api/v1/record/integrations/crm/documentos/list/"
_PATH_DOC_DESCARGAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/"
_PATH_DOC_ELIMINAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/"
_PATH_DOC_REEMPLAZAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/"
class EfcClientError(Exception):
"""Error de comunicación con EFC.
``retryable=True`` marca fallos transitorios (5xx/timeout/red) que el worker debe reintentar.
``status_code``/``code`` exponen la respuesta de EFC para que el worker pueda ramificar
(p. ej. 404 ``expediente_no_encontrado`` → ensure-then-upload).
El worker decide **por el campo ``retryable``**, nunca parseando el mensaje: un texto de error
cambia con cualquier refactor del otro repo y con él se caería la política de reintentos sin que
nada se vea roto.
"""
def __init__(self, message: str, status_code: Optional[int] = None,
code: Optional[str] = None, retryable: bool = False):
super().__init__(message)
self.status_code = status_code
self.code = code
self.retryable = retryable
class EfcClient:
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
timeout_ms: Optional[int] = None,
upload_timeout_ms: Optional[int] = None,
verify_ssl: Optional[bool] = None,
retries: int = 2,
transport: Optional[httpx.BaseTransport] = None,
):
self.base_url = (base_url if base_url is not None else settings.EFC_API_URL).rstrip("/")
self.api_key = api_key if api_key is not None else settings.EFC_API_KEY
self.timeout_s = max(0.1, float(timeout_ms or settings.EFC_API_TIMEOUT_MS) / 1000.0)
# Timeout aparte para las subidas: los 8 s de los metadatos no alcanzan para un archivo de
# 25 MB. Tiene que quedar POR DEBAJO del proxy_read_timeout del nginx de EFC — si el CRM
# esperara más, vería un 504 opaco sin saber si el documento entró. Fallando primero de este
# lado, el reintento con el mismo efc_document_ref es limpio.
self.upload_timeout_s = max(
0.1, float(upload_timeout_ms or settings.EFC_UPLOAD_TIMEOUT_MS) / 1000.0
)
self.verify_ssl = settings.EFC_API_VERIFY_SSL if verify_ssl is None else verify_ssl
self.retries = max(0, int(retries))
self.transport = transport
# mTLS (scaffolding pre-prod): si hay CA se usa para verificar; si hay par cert/key se
# presenta como certificado de cliente. Vacío = TLS normal.
self._ca_path = settings.EFC_MTLS_CA_PATH or ""
self._cert_path = settings.EFC_MTLS_CERT_PATH or ""
self._key_path = settings.EFC_MTLS_KEY_PATH or ""
def _client_kwargs(self, timeout_s: Optional[float] = None) -> dict:
"""``verify``/``cert`` para httpx según config mTLS (o TLS normal si no hay mTLS)."""
verify = self._ca_path if self._ca_path else self.verify_ssl
kwargs = {
"timeout": timeout_s or self.timeout_s,
"verify": verify,
"transport": self.transport,
}
if self._cert_path and self._key_path:
kwargs["cert"] = (self._cert_path, self._key_path)
return kwargs
@property
def is_configured(self) -> bool:
"""``False`` = integración deshabilitada (best-effort): sin URL o sin key."""
return bool(self.base_url and self.api_key)
# ── HTTP interno ──────────────────────────────────────────────────────────
def _request(self, method: str, path: str, *, json: Any = None,
params: dict = None, files: dict = None, data: dict = None,
stream: bool = False, timeout_s: Optional[float] = None):
if not self.is_configured:
raise EfcClientError("EFC no configurado (EFC_API_URL/EFC_API_KEY vacíos).", retryable=False)
url = f"{self.base_url}{path}"
headers = {"X-Api-Key": self.api_key}
last_error: Optional[Exception] = None
for attempt in range(self.retries + 1):
try:
client = httpx.Client(**self._client_kwargs(timeout_s))
try:
response = client.request(method, url, headers=headers, json=json,
params=params, files=files, data=data)
except Exception:
client.close()
raise
if 200 <= response.status_code < 300:
if stream:
# El caller lee response.content y cierra el cliente.
return response, client
client.close()
return response
# 5xx: transitorio, reintentar.
if response.status_code >= 500 and attempt < self.retries:
client.close()
time.sleep(0.15 * (attempt + 1))
continue
# 4xx u otro: no reintentar. Extraer code/mensaje de EFC.
code, message = _parse_error_body(response)
client.close()
raise EfcClientError(
message or f"EFC respondió {response.status_code}",
status_code=response.status_code,
code=code,
retryable=response.status_code >= 500,
)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_error = exc
if attempt >= self.retries:
break
time.sleep(0.15 * (attempt + 1))
except EfcClientError:
raise
except Exception as exc: # noqa: BLE001 — cualquier fallo inesperado es no-retryable
raise EfcClientError(str(exc), retryable=False) from exc
raise EfcClientError(
f"EFC inaccesible tras {self.retries + 1} intentos: {last_error}",
retryable=True,
)
# ── Organización ──────────────────────────────────────────────────────────
def buscar_organizaciones(self, q: str) -> list:
"""Búsqueda por texto, para el alta manual desde una pantalla de administración."""
return self._request("GET", _PATH_ORG_BUSCAR, params={"q": q}).json()
def resolve_organizacion(self, tenant_slug: str, tenant_name: Optional[str] = None) -> dict:
payload = {"tenant_slug": tenant_slug}
if tenant_name:
payload["tenant_name"] = tenant_name
return self._request("POST", _PATH_ORG_RESOLVER, json=payload).json()
# ── Expediente (pedimento provisional en EFC) ──────────────────────────────
def ingest_expediente(self, payload: dict) -> dict:
"""Crea el pedimento provisional del expediente. 201 si es nuevo, 200 si ya existía."""
return self._request("POST", _PATH_EXPEDIENTE, json=payload).json()
def completar_expediente(self, folio: str, payload: dict) -> dict:
"""Completa un provisional con la data aduanera real. Ningún archivo se mueve."""
path = _PATH_EXPEDIENTE_COMPLETAR.format(folio=folio)
return self._request("POST", path, json=payload).json()
def get_expediente(self, folio: str, organizacion_id: str) -> dict:
path = _PATH_EXPEDIENTE_DETALLE.format(folio=folio)
return self._request("GET", path, params={"organizacion_id": str(organizacion_id)}).json()
# ── Documentos ────────────────────────────────────────────────────────────
def upload_documento(self, organizacion_id: str, crm_company_id: int, crm_expediente_id: int,
tipo: str, filename: str, content: bytes,
content_type: str = "application/octet-stream",
crm_document_ref: Optional[str] = None) -> dict:
"""Sube un documento al expediente. Multipart, nunca base64.
``crm_document_ref`` es **el handle de NUESTRO registro de origen**, y es lo que después
permite recuperar el archivo sin guardar de este lado ningún identificador de EFC. EFC lo
guarda junto al documento con un UNIQUE parcial por ``(organizacion, ref)``, así que una
entrega repetida devuelve el documento que ya existía (200) en vez de crear otro (201).
Es la cuarta capa de idempotencia del carril y la única que garantiza la base: la entrega la
hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde—
duplicaría el documento sin esto.
"""
files = {"file": (filename, content, content_type)}
data = {
"organizacion_id": str(organizacion_id),
"crm_company_id": str(int(crm_company_id)),
"crm_expediente_id": str(int(crm_expediente_id)),
"tipo": tipo,
}
if crm_document_ref:
data["crm_document_ref"] = crm_document_ref
return self._request(
"POST", _PATH_DOCS, files=files, data=data, timeout_s=self.upload_timeout_s
).json()
def list_documentos(self, organizacion_id: str, crm_expediente_id: int, *,
tipo: Optional[str] = None,
crm_document_ref: Optional[str] = None) -> list:
"""Documentos del expediente. ``tipo`` y ``crm_document_ref`` son filtros OPCIONALES.
Preguntar por ``crm_document_ref`` es lo que permite recuperar ``efc_document_id`` si el
cache local se perdió, sin guardar identificadores ajenos como handle.
"""
params = {
"organizacion_id": str(organizacion_id),
"crm_expediente_id": str(int(crm_expediente_id)),
}
if tipo:
params["tipo"] = tipo
if crm_document_ref:
params["crm_document_ref"] = crm_document_ref
return self._request("GET", _PATH_DOCS_LIST, params=params).json()
def replace_documento(self, organizacion_id: str, doc_id: str, filename: str, content: bytes,
content_type: str = "application/octet-stream") -> dict:
"""Sustituye el CONTENIDO de un documento conservando su fila (mismo id, mismo tipo).
EFC sube primero y borra el viejo al final, así que una subida fallida deja el anterior
intacto y descargable.
"""
files = {"file": (filename, content, content_type)}
data = {"organizacion_id": str(organizacion_id)}
path = _PATH_DOC_REEMPLAZAR.format(doc_id=doc_id)
return self._request(
"PUT", path, files=files, data=data, timeout_s=self.upload_timeout_s
).json()
def download_documento(self, organizacion_id: str, doc_id: str) -> tuple[bytes, str]:
path = _PATH_DOC_DESCARGAR.format(doc_id=doc_id)
params = {"organizacion_id": str(organizacion_id)}
response, client = self._request("GET", path, params=params, stream=True)
try:
content = response.content
filename = _filename_from_response(response, default=str(doc_id))
finally:
client.close()
return content, filename
def download_url(self, doc_id: str) -> str:
"""URL absoluta del endpoint de descarga de EFC, para el proxy async de la fase 7.
El proxy no puede usar ``download_documento``: éste es síncrono y bufferiza el archivo
entero. Lo que necesita es la URL y el header, y hace su propio streaming.
"""
return f"{self.base_url}{_PATH_DOC_DESCARGAR.format(doc_id=doc_id)}"
@property
def auth_headers(self) -> dict:
"""El header de autenticación, para el proxy async que no pasa por ``_request``."""
return {"X-Api-Key": self.api_key}
def _parse_error_body(response) -> tuple[Optional[str], Optional[str]]:
"""Extrae ``(code, message)`` del cuerpo de error estructurado de EFC
(``{"error": {"code", "message"}}``) sin reventar si no es JSON."""
try:
body = response.json()
except Exception:
return None, None
err = body.get("error") if isinstance(body, dict) else None
if isinstance(err, dict):
return err.get("code"), err.get("message")
return None, None
def _filename_from_response(response, default: str) -> str:
disp = response.headers.get("Content-Disposition", "")
if "filename=" in disp:
return disp.split("filename=")[-1].strip().strip('"') or default
return default
# Instancia por defecto (lee settings). Los tests inyectan su propio transport construyendo
# EfcClient(transport=httpx.MockTransport(...)).
efc_client = EfcClient()

67
backend/core/hub_token.py Normal file
View File

@@ -0,0 +1,67 @@
"""
Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub.
Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256)
que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en
la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar.
"""
import logging
import time
from typing import Optional
import httpx
from jose import jwt
from core.config import settings
from core import session_store
logger = logging.getLogger(__name__)
def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool:
"""True si el token KC no está expirado (con margen)."""
try:
claims = jwt.get_unverified_claims(token)
exp = claims.get("exp")
return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds
except Exception:
return False
async def get_hub_access_token(request) -> Optional[str]:
"""
Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid),
refrescándolo contra el Hub si está por expirar. None si no hay sesión.
Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido).
"""
sid = request.cookies.get("crm_sid") if request is not None else None
if not sid:
return None
sess = session_store.get_session(sid)
if not sess:
return None
access = sess.get("access_token")
refresh = sess.get("refresh_token")
if access and _kc_exp_ok(access):
return access
if refresh:
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": refresh},
)
if r.status_code == 200:
data = r.json()
new_access = data.get("access_token") or access
session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh)
return new_access
logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code)
except Exception as exc:
logger.warning("get_hub_access_token: refresh falló: %s", exc)
return access

View File

@@ -0,0 +1,94 @@
"""
Sesión local del CRM (patrón SIWEB).
Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que
transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la
app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante
su ventana de inactividad, de modo que el refresh del token KC solo se intenta al
expirar la sesión local — no cada ~60s. Esto elimina el bucle de login.
Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina
(no hay re-emisión de fallback).
Marcadores del token:
- source: "local" + crm_session: True → distingue de tokens KC (RS256) y del
token dev-local (dev_local: True).
- sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap).
- exp → sliding por inactividad (idle); se
re-emite en cada refresh mientras no se supere el cap.
Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07).
Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto
(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey.
"""
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
# Claims de identidad que se propagan del token KC a la sesión local.
_IDENTITY_CLAIMS = (
"sub", "email", "preferred_username", "username", "name",
"given_name", "family_name", "first_name", "last_name",
"tenant_id", "tenant_slug", "roles", "permissions",
"is_hub_admin", "avatar_url",
)
def _now_epoch() -> int:
return int(datetime.now(timezone.utc).timestamp())
def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str:
"""
Emite un JWT de sesión local a partir de los claims (verificados) del usuario.
`session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si
no se provee, se usa el momento actual (sesión nueva).
"""
now = _now_epoch()
sst = int(session_start) if session_start else now
payload: Dict[str, Any] = {
k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None
}
payload.update({
"source": "local",
"crm_session": True,
"sst": sst,
"iat": now,
"exp": now + settings.SESSION_IDLE_MINUTES * 60,
})
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
"""
Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por
inactividad y no superó el cap absoluto de vida; None en cualquier otro caso.
Nunca lanza (para poder encadenar con la validación contra el Hub).
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except JWTError:
return None
# Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local).
if not payload.get("crm_session") or payload.get("source") != "local":
return None
# Cap absoluto de vida de sesión (independiente del sliding por idle).
sst = payload.get("sst")
if isinstance(sst, (int, float)):
if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600:
return None
return payload
def session_start_of(payload: Dict[str, Any]) -> Optional[int]:
"""Extrae el epoch de inicio de sesión (sst) de un payload de sesión local."""
sst = payload.get("sst")
return int(sst) if isinstance(sst, (int, float)) else None

View File

@@ -3,6 +3,7 @@ import time
import httpx
from datetime import datetime, timezone
from typing import Callable, Optional
from cachetools import TTLCache
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system
logger = logging.getLogger(__name__)
# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al
# Hub en cada request. Valor: "valid" o "invalid:<mensaje>". TTL corto para que
# los cambios de licencia se propaguen en minutos.
_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600)
def _normalize_text(value: str | None) -> str:
if not value:
@@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
token = auth_header.split(" ")[1]
# Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que
# el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC
# guardado en valkey y se cachea por tenant.
if getattr(settings, "SESSION_STORE_ENABLED", False):
try:
from core.local_session import verify_session_token
local_claims = verify_session_token(token)
except Exception:
local_claims = None
if local_claims is not None:
return await self._handle_local_session_license(request, call_next, local_claims)
tenant_override = request.headers.get("X-Tenant-Override")
if not tenant_override:
# Fallback para flujos SSO cuando el override no viaja en header.
@@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
}
)
async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict):
"""
Valida licencia para una sesión local del CRM (patrón SIWEB).
El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en
valkey (refrescándolo si está vencido) para consultar verify-license, con
caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se
permite el paso: la sesión local se emitió tras un login válido (el App
Launcher solo ofrece apps licenciadas), evitando bloquear por un problema
transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean.
"""
from core import session_store
tenant_key = str(local_claims.get("tenant_id") or "")
cached = _license_cache.get(tenant_key) if tenant_key else None
if cached == "valid":
return await call_next(request)
if isinstance(cached, str) and cached.startswith("invalid:"):
return JSONResponse(
status_code=402,
content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402},
)
tenant_override = (
tenant_key
or request.cookies.get("sso_tenant_id")
or request.cookies.get("sso_tenant_pub")
or ""
)
sid = request.cookies.get("crm_sid")
sess = session_store.get_session(sid) if sid else None
kc_token = (sess or {}).get("access_token") or ""
kc_refresh = (sess or {}).get("refresh_token") or ""
async def _verify(tok: str):
if not tok:
return None
headers = {"Authorization": f"Bearer {tok}"}
if tenant_override:
headers["X-Tenant-Override"] = str(tenant_override)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
return await client.get(
f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers
)
except Exception as exc:
logger.warning("[license] verify-license (sesión local) error de red: %s", exc)
return None
resp = await _verify(kc_token)
# ¿El KC token guardado está vencido? Refrescar una vez y reintentar.
needs_refresh = resp is None or resp.status_code == 401
if not needs_refresh and resp.status_code == 200:
try:
_d = resp.json()
except Exception:
_d = {}
if not _d.get("valid", False) and _is_token_issue_message(
_d.get("message"), _d.get("detail"), _d.get("reason")
):
needs_refresh = True
if needs_refresh and kc_refresh:
try:
async with httpx.AsyncClient(timeout=8.0) as client:
rr = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": kc_refresh},
)
if rr.status_code == 200:
nt = rr.json()
kc_token = nt.get("access_token") or kc_token
if sid:
session_store.update_session_tokens(
sid, kc_token, nt.get("refresh_token") or kc_refresh
)
resp = await _verify(kc_token)
else:
logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code)
except Exception as exc:
logger.warning("[license] refresh KC para verify-license falló: %s", exc)
if resp is not None and resp.status_code == 200:
try:
data = resp.json()
except Exception:
data = {}
if data.get("valid", False):
expires_at_str = data.get("expires_at")
if expires_at_str:
try:
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción."
if tenant_key:
_license_cache[tenant_key] = f"invalid:{msg}"
return JSONResponse(
status_code=402,
content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402},
)
except (ValueError, TypeError):
pass
if tenant_key:
_license_cache[tenant_key] = "valid"
request.state.license_info = data
return await call_next(request)
message = data.get("message", "Sin licencia asignada para este tenant")
if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")):
if tenant_key:
_license_cache[tenant_key] = f"invalid:{message}"
return JSONResponse(
status_code=402,
content={"error": "LICENSE_ERROR", "message": message, "status_code": 402},
)
# No concluyente (Hub no dio 200, o el problema de token persiste porque su
# refresh falla): la sesión local es válida → permitir sin cachear. Evita el
# bucle de 401 por el bug de refresh del Hub.
logger.warning(
"[license] verify-license no concluyente para sesión local (tenant=%s) — se permite",
tenant_key,
)
return await call_next(request)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""

View File

@@ -250,30 +250,6 @@ def csv_import_key(
)
def expediente_document_key(
tenant_id: Union[int, str],
company_id: int,
expediente_id: int,
unique_token: str,
original_filename: str,
) -> str:
"""
Documento de un expediente del CRM, bajo
``tenants/{tid}/companies/{cid}/expedientes/{expediente_id}/documents/{token}_{filename}``.
Es una **copia de tránsito**: el destino final del archivo es el expediente electrónico de EFC,
y al confirmar la entrega esta copia se borra (``delete_local`` del outbox). Vive bajo el árbol
por tenant/company igual que todo lo demás, para que el aislamiento sea el mismo.
NO confundir con ``expediente_archivo_document_key``: aquélla se refiere al expediente del
**importador** de EFC, que cuelga de un RFC y es otro concepto. Es código muerto de la plantilla.
"""
eid = _segment(expediente_id, "expediente_id")
token = _segment(unique_token, "unique_token")
fn = safe_filename(original_filename)
return f"{tenant_company_prefix(tenant_id, company_id)}expedientes/{eid}/documents/{token}_{fn}"
def legacy_csv_import_key(job_type: str, job_id: str) -> str:
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
_segment(job_id, "job_id")

View File

@@ -47,6 +47,19 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
if cache_key in token_cache:
return token_cache[cache_key]
# Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada
# (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh
# del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que
# elimina el bucle de login. verify_session_token retorna None para tokens KC
# (RS256), así que no interfiere con el flujo normal.
if settings.SESSION_STORE_ENABLED:
from core.local_session import verify_session_token
local_claims = verify_session_token(token)
if local_claims is not None:
token_cache[cache_key] = local_claims
return local_claims
# Shortcut para tokens de desarrollo local
if settings.DEV_LOCAL_AUTH:
try:
@@ -653,6 +666,20 @@ def validate_access_to_resource(
tenant_id = resolve_effective_tenant_id_from_user(current_user)
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
# por compañía seleccionada cuando el token no está ligado a un tenant.
if tenant_id is None and company_id:
try:
from sqlalchemy import text as _text
row = db.execute(
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
).first()
if row and row[0] is not None:
tenant_id = int(row[0])
except Exception as exc:
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
# Se reemplazó el antiguo "admin" in realm_access.roles para que la

View File

@@ -0,0 +1,111 @@
"""
Store de sesión en Valkey/Redis (patrón SIWEB).
Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por
un session_id opaco. La app usa la sesión local firmada (ver core.local_session)
para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub
(provisioning, my-apps, my-tenants), refrescándolos best-effort.
Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op
y la sesión local firmada sigue sosteniendo la app.
"""
import json
import logging
import uuid
from typing import Optional
from core.config import settings
try:
import redis # type: ignore
except Exception: # pragma: no cover - redis es opcional en algunos entornos
redis = None # type: ignore
logger = logging.getLogger(__name__)
_KEY_PREFIX = "crm:session:"
_client = None
def _get_client():
"""Cliente Redis/Valkey compartido (perezoso). None si no está disponible."""
global _client
if redis is None:
return None
if _client is None:
try:
_client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True)
except Exception as exc:
logger.warning("session_store_init_failed: %s", exc)
return None
return _client
def _ttl_seconds() -> int:
# La sesión en valkey vive como máximo lo que la vida absoluta de la sesión.
return settings.SESSION_MAX_HOURS * 3600
def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]:
"""Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está)."""
client = _get_client()
if client is None:
return None
session_id = str(uuid.uuid4())
data = json.dumps({
"access_token": access_token,
"refresh_token": refresh_token or "",
"sst": int(session_start),
})
try:
client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data)
return session_id
except Exception as exc:
logger.warning("session_store_create_failed: %s", exc)
return None
def get_session(session_id: str) -> Optional[dict]:
"""Devuelve {access_token, refresh_token, sst} de la sesión, o None."""
client = _get_client()
if client is None or not session_id:
return None
try:
raw = client.get(f"{_KEY_PREFIX}{session_id}")
return json.loads(raw) if raw else None
except Exception as exc:
logger.warning("session_store_get_failed: %s", exc)
return None
def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None:
"""Actualiza los tokens KC de una sesión existente conservando su TTL y su sst."""
client = _get_client()
if client is None or not session_id:
return
try:
key = f"{_KEY_PREFIX}{session_id}"
ttl = client.ttl(key)
if ttl and ttl > 0:
existing = client.get(key)
sst = json.loads(existing).get("sst") if existing else None
data = json.dumps({
"access_token": access_token,
"refresh_token": refresh_token or "",
"sst": sst,
})
client.setex(key, ttl, data)
except Exception as exc:
logger.warning("session_store_update_failed: %s", exc)
def delete_session(session_id: str) -> None:
"""Elimina la sesión (logout). Fail-silent."""
client = _get_client()
if client is None or not session_id:
return
try:
client.delete(f"{_KEY_PREFIX}{session_id}")
except Exception as exc:
logger.warning("session_store_delete_failed: %s", exc)

View File

@@ -82,31 +82,6 @@ def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> Non
put_object_bytes(key, body, content_type=content_type)
def put_object_stream(key: str, fileobj, content_type: str = "application/octet-stream") -> None:
"""Sube un objeto **sin materializarlo en memoria**, leyendo del descriptor por partes.
``put_object_bytes`` recibe los bytes ya completos, así que quien lo llama tuvo que
bufferizar el archivo entero. Para una subida de usuario eso significa que un archivo de 2 GB
ocupa 2 GB de RAM del proceso **antes** de que nadie valide su tamaño. ``upload_fileobj`` de
boto3 lee del descriptor por partes y sube en multipart cuando hace falta.
"""
_client().upload_fileobj(
fileobj,
settings.S3_BUCKET,
key,
ExtraArgs={"ContentType": content_type},
)
def open_object_stream(key: str):
"""Devuelve el cuerpo del objeto como flujo, para servirlo sin cargarlo entero en memoria.
El llamador es responsable de cerrarlo (``.close()``): un ``StreamingBody`` sin cerrar retiene
la conexión del pool hasta que el recolector pase.
"""
return _client().get_object(Bucket=settings.S3_BUCKET, Key=key)["Body"]
def get_object_bytes(key: str) -> bytes:
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
return resp["Body"].read()

View File

@@ -28,10 +28,11 @@ from core.database import Base # noqa: E402
import api.v1.modules.crm.accounts.models # noqa: E402,F401
import api.v1.modules.crm.activities.models # noqa: E402,F401
import api.v1.modules.crm.addresses.models # noqa: E402,F401
import api.v1.modules.crm.cases.models # noqa: E402,F401
import api.v1.modules.crm.catalogs.models # noqa: E402,F401
import api.v1.modules.crm.common.folios # noqa: E402,F401
import api.v1.modules.crm.contacts.models # noqa: E402,F401
import api.v1.modules.crm.documents.models # noqa: E402,F401
import api.v1.modules.crm.expediente_gateway.models # noqa: E402,F401
import api.v1.modules.crm.expedientes.models # noqa: E402,F401
import api.v1.modules.crm.leads.models # noqa: E402,F401
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
import api.v1.modules.crm.pipelines.models # noqa: E402,F401

View File

@@ -1,182 +0,0 @@
{
"_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": "<string>", "message": "<string>"}},
"_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": "<int>"},
"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."
}
}
}

View File

@@ -15,7 +15,7 @@ def test_create_and_get_account(db):
)
assert acc.id is not None
assert acc.status == "active"
assert acc.country == "MX"
assert acc.country == "MEX" # ISO 3166-1 alfa-3 (alineado al catálogo pais)
got = service.get_account(db, acc.id, T, C)
assert got.name == "Importadora Demo"
assert got.rfc == "XAXX010101000"

View File

@@ -0,0 +1,48 @@
"""Pruebas del Expediente (crm.cases): minteo, propagación y timeline."""
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.opportunities import service as opp_service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.quotes import service as q_service
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate, ServiceRequestFromOpportunityInput
T, C = 1, 1
def test_opportunity_mints_expediente(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C)
assert opp.case_id is not None
case = cases_service.get_case(db, opp.case_id, T, C)
assert (case.reference or "").startswith("EXP")
assert case.stage == "oportunidad"
def test_case_propagates_and_advances(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="importacion"), T, C)
sr = sr_service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C)
assert sr.case_id == opp.case_id
assert cases_service.get_case(db, opp.case_id, T, C).stage == "solicitud"
quotes = q_service.create_quotes_from_service_request(db, sr.id, T, C)
assert quotes[0].case_id == opp.case_id
case = cases_service.get_case(db, opp.case_id, T, C)
assert case.stage == "cotizacion"
# El timeline reúne toda la historia ligada al expediente
kinds = {e["kind"] for e in cases_service.build_timeline(db, case)}
assert {"oportunidad", "solicitud", "cotizacion"} <= kinds
def test_direct_service_request_mints_expediente(db):
# Solicitud directa (sin oportunidad) también obtiene expediente (fallback)
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
assert sr.case_id is not None
assert cases_service.get_case(db, sr.case_id, T, C).stage == "solicitud"
def test_advance_stage_never_regresses(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="N", operation_type="exportacion"), T, C)
cases_service.advance_stage(db, opp.case_id, "facturacion")
cases_service.advance_stage(db, opp.case_id, "solicitud") # no debe retroceder
assert cases_service.get_case(db, opp.case_id, T, C).stage == "facturacion"

View File

@@ -0,0 +1,49 @@
"""Pruebas de la siembra idempotente de catálogos globales del CRM."""
from api.v1.modules.crm.catalogs.models import CatalogItem
from api.v1.modules.crm.catalogs.seed import seed_global_catalogs
# Catálogos nuevos del proceso comercial y una clave base que debe existir en cada uno.
NEW_CATALOGS = {
"tipo_operacion": "importacion",
"medio_transporte": "maritimo",
"tipo_servicio": "puerto_puerto",
"prioridad": "urgente",
"tipo_mercancia": "peligrosa",
"unidad_medida": "kg",
"tipo_embalaje": "pallet",
"servicio_adicional": "seguro",
"tipo_documento": "factura_comercial",
}
def _codes(db, catalog: str) -> set[str]:
return {
row.code
for row in db.query(CatalogItem.code).filter(
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
)
}
def test_seed_creates_new_catalogs(db):
seed_global_catalogs(db)
for catalog, base_code in NEW_CATALOGS.items():
codes = _codes(db, catalog)
assert codes, f"El catálogo {catalog} quedó vacío"
assert base_code in codes, f"Falta la clave base {base_code} en {catalog}"
def test_seed_is_idempotent(db):
first = seed_global_catalogs(db)
assert first, "La primera corrida debió sembrar filas"
second = seed_global_catalogs(db)
assert second == {}, "La segunda corrida no debe agregar filas nuevas"
def test_pais_catalog_populated(db):
"""El catálogo pais alimenta Origen/Destino de la solicitud (decisión 6)."""
seed_global_catalogs(db)
codes = _codes(db, "pais")
assert len(codes) > 100
assert "MEX" in codes

View File

@@ -1,292 +0,0 @@
"""Afirmación del lado CRM del contrato con EFC.
``tests/contracts/efc_crm_contract.json`` es el contrato; esto comprueba que **este** repo lo
cumple. EFC debe afirmar su mitad contra una copia idéntica del mismo archivo.
Por qué existe, y por qué no basta con las otras pruebas: CRM y EFC se despliegan por separado. Las
pruebas de `test_efc_client.py` verifican que el cliente se comporta bien contra el EFC que el
cliente **cree** que existe; si EFC renombra una ruta o una clave del payload, esas pruebas siguen
verdes y el carril se rompe en producción. Lo único que atrapa esa deriva es un contrato escrito
aparte y afirmado desde los dos lados.
Nada aquí toca la red: las peticiones se capturan con ``httpx.MockTransport``.
"""
import json
from pathlib import Path
import httpx
import pytest
from core.efc_client import EfcClient
CONTRATO = json.loads(
(Path(__file__).parent / "contracts" / "efc_crm_contract.json").read_text(encoding="utf-8")
)
CARRIL = CONTRATO["carril_efc"]
API_CRM = CONTRATO["api_crm"]
@pytest.fixture()
def capturadas():
"""Cliente contra EFC simulado que va guardando las peticiones que salen."""
peticiones: list[httpx.Request] = []
def handler(request):
peticiones.append(request)
if request.method == "GET" and request.url.path.endswith("/list/"):
return httpx.Response(200, json=[])
return httpx.Response(200, json={"id": "org-1"})
cliente = EfcClient(
base_url="https://efc.example.test",
api_key="llave-de-prueba",
timeout_ms=500,
upload_timeout_ms=500,
verify_ssl=False,
transport=httpx.MockTransport(handler),
)
return cliente, peticiones
# ── Las rutas del carril ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
"nombre,llamada",
[
("organizaciones_buscar", lambda c: c.buscar_organizaciones("temex")),
("organizaciones_resolver", lambda c: c.resolve_organizacion("temex", "TEMEX")),
("expediente_crear", lambda c: c.ingest_expediente({"folio": "EXP2026-08-001"})),
("expediente_completar", lambda c: c.completar_expediente("EXP2026-08-001", {})),
("expediente_detalle", lambda c: c.get_expediente("EXP2026-08-001", "org-1")),
("documentos_listar", lambda c: c.list_documentos("org-1", 42)),
],
)
def test_cada_llamada_del_cliente_pega_en_la_ruta_del_contrato(capturadas, nombre, llamada):
cliente, peticiones = capturadas
esperado = CARRIL["endpoints"][nombre]
llamada(cliente)
peticion = peticiones[-1]
assert peticion.method == esperado["metodo"]
assert peticion.url.path == _resolver(esperado["path"])
def test_la_descarga_apunta_a_la_ruta_del_contrato(capturadas):
"""``download_url`` la arma a mano para el proxy async, así que se comprueba aparte."""
cliente, _ = capturadas
esperado = CARRIL["endpoints"]["documento_descargar"]
url = httpx.URL(cliente.download_url("doc-1"))
assert url.path == _resolver(esperado["path"], doc_id="doc-1")
def test_la_subida_de_documento_pega_en_su_ruta_y_va_en_multipart(capturadas):
cliente, peticiones = capturadas
esperado = CARRIL["endpoints"]["documento_subir"]
cliente.upload_documento("org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf")
peticion = peticiones[-1]
assert peticion.method == esperado["metodo"]
assert peticion.url.path == esperado["path"]
assert esperado["content_type"] in peticion.headers["content-type"]
def test_el_reemplazo_de_documento_pega_en_su_ruta(capturadas):
cliente, peticiones = capturadas
esperado = CARRIL["endpoints"]["documento_reemplazar"]
cliente.replace_documento("org-1", "doc-1", "guia.pdf", b"%PDF-1.4")
peticion = peticiones[-1]
assert peticion.method == esperado["metodo"]
assert peticion.url.path == _resolver(esperado["path"], doc_id="doc-1")
def test_el_cliente_NO_sabe_borrar_documentos_en_efc():
"""El endpoint de borrado existe en EFC y el CRM **deliberadamente no lo llama**.
``record.Document`` 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. La baja en el CRM es lógica. Que el método no exista es lo que
impide que alguien lo llame "porque estaba ahí": esta prueba se pone roja si aparece.
"""
metodos = {m for m in dir(EfcClient) if "elimin" in m or "delete" in m or "borrar" in m}
assert metodos == set(), f"apareció una operación de borrado hacia EFC: {metodos}"
assert "documento_eliminar" in CARRIL["endpoints"], "el endpoint existe del lado de EFC"
def _resolver(plantilla: str, **valores) -> str:
"""Rellena los marcadores de la plantilla con los valores que usan las pruebas."""
defaults = {"folio": "EXP2026-08-001", "doc_id": "doc-1"}
defaults.update(valores)
return plantilla.format(**defaults)
# ── Las formas de los payloads ───────────────────────────────────────────────
def test_el_alta_de_expediente_manda_exactamente_las_claves_del_contrato(capturadas):
"""Ni una de más ni una de menos.
Una clave de menos y EFC responde ``payload_invalido``; una de más y el serializer de EFC la
ignora en silencio, que es peor: el dato se cree enviado y no lo está.
"""
cliente, peticiones = capturadas
esperadas = set(CARRIL["endpoints"]["expediente_crear"]["request_claves"])
cliente.ingest_expediente({clave: "x" for clave in esperadas})
assert set(json.loads(peticiones[-1].content)) == esperadas
def test_la_subida_manda_los_campos_de_formulario_del_contrato(capturadas):
cliente, peticiones = capturadas
esperados = set(CARRIL["endpoints"]["documento_subir"]["form_claves"])
archivo = CARRIL["endpoints"]["documento_subir"]["archivo_campo"]
cliente.upload_documento(
"org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf",
crm_document_ref="CRMDOC-1-7",
)
cuerpo = peticiones[-1].content.decode("latin-1")
faltantes = [c for c in esperados if f'name="{c}"' not in cuerpo]
assert faltantes == [], f"el multipart no lleva {faltantes}"
assert f'name="{archivo}"' in cuerpo
def test_toda_peticion_del_carril_lleva_el_header_de_autenticacion(capturadas):
cliente, peticiones = capturadas
header = CARRIL["header_autenticacion"]
cliente.resolve_organizacion("temex")
cliente.ingest_expediente({"folio": "EXP2026-08-001"})
cliente.upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x")
assert peticiones, "no salió ninguna petición"
for peticion in peticiones:
assert peticion.headers.get(header) == "llave-de-prueba"
# ── El catálogo de errores ───────────────────────────────────────────────────
def test_el_code_que_dispara_el_ensure_then_upload_esta_en_el_catalogo():
"""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 más frágil del carril."""
critico = CARRIL["codigos_con_significado_para_el_crm"]["expediente_no_encontrado"]
assert critico["http"] == 404
assert "expediente_no_encontrado" in CARRIL["codigos_error"]["404"]
def test_el_gateway_ramifica_por_el_code_exacto_del_contrato():
"""El código del CRM tiene ese ``code`` escrito literal. Que coincida con el contrato es lo que
esta prueba fija; que el contrato coincida con EFC lo fija la suite del otro repo."""
from pathlib import Path as _Path
fuente = (
_Path(__file__).parent.parent
/ "api" / "v1" / "modules" / "crm" / "expediente_gateway" / "service.py"
).read_text(encoding="utf-8")
assert '"expediente_no_encontrado"' in fuente
def test_el_cliente_extrae_el_code_del_formato_de_error_del_contrato():
forma = CARRIL["formato_error"]["forma"]
assert set(forma) == {"error"}
assert set(forma["error"]) == {"code", "message"}
def handler(request):
return httpx.Response(400, json={"error": {"code": "espacio_insuficiente", "message": "m"}})
from core.efc_client import EfcClientError
cliente = EfcClient(
base_url="https://efc.example.test", api_key="k", timeout_ms=200,
verify_ssl=False, transport=httpx.MockTransport(handler),
)
with pytest.raises(EfcClientError) as exc:
cliente.resolve_organizacion("temex")
assert exc.value.code == "espacio_insuficiente"
assert exc.value.code in CARRIL["codigos_error"]["400"]
assert exc.value.retryable is False
# ── El API de usuario del CRM ────────────────────────────────────────────────
def test_las_rutas_registradas_del_crm_son_las_del_contrato():
"""Cubre las dos direcciones: ninguna del contrato sin registrar, y ninguna registrada de más
en estos dos routers. Un endpoint que aparece sin estar en el contrato es un endpoint que
nadie del otro lado sabe que existe."""
from api.v1.modules.crm.expediente_gateway.routes import router as gateway_router
from api.v1.modules.crm.expedientes.routes import router as expedientes_router
registradas = set()
for router in (expedientes_router, gateway_router):
for ruta in router.routes:
# ``ruta.path`` ya trae el prefijo del router aplicado: concatenarlo lo duplicaría.
for metodo in ruta.methods:
if metodo in ("HEAD", "OPTIONS"):
continue
registradas.add((metodo, ruta.path))
del_contrato = {(e["metodo"], e["path"]) for e in API_CRM["endpoints"]}
assert del_contrato - registradas == set(), "el contrato declara rutas que no existen"
assert registradas - del_contrato == set(), "hay rutas fuera del contrato"
def test_la_respuesta_de_un_documento_nunca_expone_la_copia_local():
"""La copia local se borra al confirmar la entrega a EFC: una referencia expuesta al frontend
es una referencia que va a dejar de existir."""
from api.v1.modules.crm.expedientes.dto import ExpedienteDocumentResponse
campos = set(ExpedienteDocumentResponse.model_fields)
for prohibido in API_CRM["documento_response_prohibido"]:
assert prohibido not in campos, f"la respuesta expone '{prohibido}'"
faltantes = [c for c in API_CRM["documento_response_claves_minimas"] if c not in campos]
assert faltantes == [], f"la respuesta no lleva {faltantes}"
def test_los_estados_de_sincronizacion_son_los_del_contrato():
from api.v1.modules.crm.expediente_gateway.models import (
STATUS_FAILED,
STATUS_PENDING,
STATUS_SENT,
)
# Los del outbox son en minúsculas; los que ve el frontend en el documento, en mayúsculas.
assert {STATUS_PENDING, STATUS_SENT, STATUS_FAILED} == {"pending", "sent", "failed"}
assert set(API_CRM["estados_sincronizacion"]) == {"PENDING", "SYNCED", "FAILED"}
def test_las_metricas_del_outbox_tienen_las_claves_del_contrato(db):
from api.v1.modules.crm.expediente_gateway import service as gateway
from tests.conftest import COMPANY_ID, TENANT_ID
metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)
assert set(API_CRM["metricas_outbox_claves"]).issubset(set(metricas))
def test_el_formato_del_folio_es_el_del_contrato(db):
"""El folio es lo que el usuario ve al guardar y lo que enlaza al expediente con EFC: su forma
es contrato, no detalle."""
import re
from api.v1.modules.crm.expedientes.folio import next_folio
from tests.conftest import COMPANY_ID, TENANT_ID
folio, _, _, _ = next_folio(db, TENANT_ID, COMPANY_ID)
patron = (
API_CRM["folio"]["formato"]
.replace("{YYYY}", r"\d{4}")
.replace("{MM}", r"\d{2}")
.replace("{NNN}", r"\d{3,}")
)
assert re.fullmatch(patron, folio), f"{folio} no cumple {API_CRM['folio']['formato']}"

View File

@@ -1,85 +0,0 @@
"""Paridad del catálogo de tipos de documento entre el CRM y EFC.
``EFC_DOC_TYPES`` del CRM tiene que ser **exactamente** el juego de claves de
``TIPOS_DOCUMENTO_CRM`` de ``api/record/views_integrations_crm.py`` en EFC. La lista está duplicada
a mano en dos repos con despliegue independiente, y este archivo es lo único que la mantiene
honesta: si alguien agrega un tipo de un solo lado, esto se pone rojo **antes** de que un documento
se rechace en producción con ``tipo_invalido``.
El juego esperado va escrito **literal** aquí y no derivado de ``doc_types.py``, porque una prueba
que se lo pregunte al mismo módulo que valida no prueba nada: pasaría con cualquier cambio.
"""
from api.v1.modules.crm.expedientes.doc_types import EFC_DOC_TYPES, is_valid_doc_type
# Copia literal de las claves de TIPOS_DOCUMENTO_CRM (EFC, fase 3 del ticket T2026-08-046).
# Al cambiar EFC, se cambia AQUÍ y el rojo obliga a mirar los dos lados.
CLAVES_EN_EFC = {
# crm.documents — DOC_TYPES de frontend/src/lib/api/crm/format.ts
"constancia_fiscal",
"acta_constitutiva",
"identificacion",
"comprobante_domicilio",
"contrato",
"presentacion",
"certificacion",
"licencia",
"convenio",
"tarifario",
# ops.shipment_documents — SHIPMENT_DOC_TYPES del mismo archivo
"MBL",
"HBL",
"MAWB",
"HAWB",
"CMR",
"factura_comercial",
"packing_list",
"carta_encomienda",
"carta_garantia",
"certificado_permiso",
# fin.invoices
"factura_venta",
# 'otro' existe en AMBAS listas del CRM y significa lo mismo: una sola entrada
"otro",
}
def test_el_catalogo_del_crm_es_identico_al_de_efc():
faltan_en_crm = CLAVES_EN_EFC - EFC_DOC_TYPES
sobran_en_crm = EFC_DOC_TYPES - CLAVES_EN_EFC
assert not faltan_en_crm, f"EFC acepta tipos que el CRM no conoce: {sorted(faltan_en_crm)}"
assert not sobran_en_crm, (
f"El CRM mandaría tipos que EFC va a rechazar con tipo_invalido: {sorted(sobran_en_crm)}"
)
def test_son_exactamente_veintidos():
"""El número está en el ticket. Si cambia, es un cambio de contrato entre dos repos."""
assert len(EFC_DOC_TYPES) == 22
def test_no_hay_duplicados_entre_las_tres_fuentes():
"""``otro`` está en las dos listas del CRM y debe colapsar a UNA entrada.
Un ``frozenset`` lo colapsa solo; la prueba está para que una futura refactorización a lista o
a tupla no reintroduzca el duplicado en silencio.
"""
from api.v1.modules.crm.expedientes import doc_types
todas = (
doc_types._TIPOS_DOCUMENTOS_CLIENTE
+ doc_types._TIPOS_DOCUMENTOS_EMBARQUE
+ doc_types._TIPOS_FACTURACION
+ doc_types._TIPOS_COMUNES
)
assert len(todas) == len(set(todas))
def test_un_tipo_fuera_del_catalogo_se_rechaza():
"""El CRM valida ANTES de gastar un viaje de red, y evita que un typo cree un DocumentType
basura en el catálogo GLOBAL de EFC, que comparten todas las organizaciones."""
assert is_valid_doc_type("MBL") is True
assert is_valid_doc_type("mbl") is False # sensible a mayúsculas, como el catálogo de EFC
assert is_valid_doc_type("factura_de_venta") is False # typo de 'factura_venta'
assert is_valid_doc_type("") is False
assert is_valid_doc_type(None) is False

View File

@@ -1,248 +0,0 @@
"""Pruebas del cliente HTTP hacia EFC.
**Existen porque el carril de referencia no las tiene.** Verificado: en el gateway de Anexo22 no hay
ni una prueba de ``EfcClient._request``, así que su bucle de reintentos, su backoff, su corte en 4xx
y su header nunca se ejercitan. Ese hueco no se clona.
Todo va contra ``httpx.MockTransport`` por el parámetro ``transport``, que existe justamente para
esto: **ninguna de estas pruebas toca la red**.
"""
import httpx
import pytest
from core.efc_client import EfcClient, EfcClientError
BASE = "https://efc.example.test"
KEY = "llave-de-prueba"
def _client(handler, **kwargs) -> EfcClient:
return EfcClient(
base_url=kwargs.pop("base_url", BASE),
api_key=kwargs.pop("api_key", KEY),
timeout_ms=kwargs.pop("timeout_ms", 500),
upload_timeout_ms=kwargs.pop("upload_timeout_ms", 500),
verify_ssl=False,
transport=httpx.MockTransport(handler),
**kwargs,
)
def test_reintenta_un_500_y_devuelve_el_exito():
intentos = {"n": 0}
def handler(request):
intentos["n"] += 1
if intentos["n"] == 1:
return httpx.Response(500, json={"detail": "boom"})
return httpx.Response(200, json={"id": "org-1"})
resp = _client(handler).resolve_organizacion("temex")
assert resp == {"id": "org-1"}
assert intentos["n"] == 2
def test_un_500_permanente_hace_exactamente_tres_intentos_y_es_retryable():
"""``retries = 2`` significa 3 intentos: el original + 2. Ni 2 ni 4."""
intentos = {"n": 0}
def handler(request):
intentos["n"] += 1
return httpx.Response(500, json={"detail": "boom"})
with pytest.raises(EfcClientError) as exc:
_client(handler).resolve_organizacion("temex")
assert intentos["n"] == 3
assert exc.value.retryable is True
def test_un_timeout_permanente_hace_tres_intentos_y_es_retryable():
intentos = {"n": 0}
def handler(request):
intentos["n"] += 1
raise httpx.ConnectTimeout("se acabó el tiempo", request=request)
with pytest.raises(EfcClientError) as exc:
_client(handler).resolve_organizacion("temex")
assert intentos["n"] == 3
assert exc.value.retryable is True
def test_un_400_no_se_reintenta_y_extrae_el_code_del_cuerpo():
"""El corte en 4xx es lo que evita machacar a EFC con una petición que nunca va a pasar.
Y el ``code`` extraído es lo que permite al worker ramificar **por campo**, nunca parseando el
texto del mensaje: un texto cambia con cualquier refactor del otro repo.
"""
intentos = {"n": 0}
def handler(request):
intentos["n"] += 1
return httpx.Response(
400,
json={"error": {"code": "espacio_insuficiente", "message": "La licencia no tiene espacio"}},
)
with pytest.raises(EfcClientError) as exc:
_client(handler).resolve_organizacion("temex")
assert intentos["n"] == 1
assert exc.value.status_code == 400
assert exc.value.code == "espacio_insuficiente"
assert exc.value.retryable is False
assert "La licencia no tiene espacio" in str(exc.value)
@pytest.mark.parametrize("status_code", [401, 403])
def test_401_y_403_no_se_reintentan(status_code):
"""Una key mal configurada no mejora insistiendo: reintentarla solo gasta cuota y llena logs."""
intentos = {"n": 0}
def handler(request):
intentos["n"] += 1
return httpx.Response(status_code)
with pytest.raises(EfcClientError) as exc:
_client(handler).resolve_organizacion("temex")
assert intentos["n"] == 1
assert exc.value.retryable is False
def test_un_cuerpo_de_error_que_no_es_json_no_revienta():
def handler(request):
return httpx.Response(400, text="<html>502 Bad Gateway</html>")
with pytest.raises(EfcClientError) as exc:
_client(handler).resolve_organizacion("temex")
assert exc.value.code is None
assert exc.value.status_code == 400
def test_toda_llamada_manda_el_header_x_api_key():
visto = {}
def handler(request):
visto["key"] = request.headers.get("X-Api-Key")
return httpx.Response(200, json={"id": "org-1"})
_client(handler).resolve_organizacion("temex")
assert visto["key"] == KEY
def test_sin_url_configurada_no_toca_la_red_y_el_error_no_es_retryable():
"""``is_configured is False`` es lo que hace que todo el carril sea best-effort.
Si esto tocara la red, cada operación del CRM con EFC apagado pagaría un timeout.
"""
llamado = {"n": 0}
def handler(request):
llamado["n"] += 1
return httpx.Response(200, json={})
client = _client(handler, base_url="")
assert client.is_configured is False
with pytest.raises(EfcClientError) as exc:
client.resolve_organizacion("temex")
assert llamado["n"] == 0
assert exc.value.retryable is False
def test_sin_api_key_tampoco_esta_configurado():
def handler(request):
return httpx.Response(200, json={})
assert _client(handler, api_key="").is_configured is False
def test_la_base_url_con_y_sin_barra_final_dan_la_misma_url():
urls = []
def handler(request):
urls.append(str(request.url))
return httpx.Response(200, json={"id": "org-1"})
_client(handler, base_url=BASE).resolve_organizacion("temex")
_client(handler, base_url=BASE + "/").resolve_organizacion("temex")
assert urls[0] == urls[1]
assert "//organization" not in urls[0]
def test_la_subida_va_multipart_y_lleva_el_crm_document_ref():
"""El ref es la tercera capa de idempotencia: EFC devuelve 200 con el que ya existía."""
visto = {}
def handler(request):
visto["content_type"] = request.headers.get("Content-Type", "")
visto["body"] = request.content
return httpx.Response(201, json={"id": "doc-1"})
resp = _client(handler).upload_documento(
"org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4 contenido", "application/pdf",
crm_document_ref="SHPDOC-1-4471",
)
assert resp == {"id": "doc-1"}
assert visto["content_type"].startswith("multipart/form-data")
assert b"SHPDOC-1-4471" in visto["body"]
assert b"%PDF-1.4 contenido" in visto["body"]
# Nada de base64: el archivo viaja crudo dentro del multipart.
assert b"base64" not in visto["body"]
def test_la_subida_usa_el_timeout_largo_y_los_metadatos_el_corto():
"""Los 8 s de los metadatos no alcanzan para un archivo de 25 MB, y un timeout de subida
demasiado largo haría que el CRM vea un 504 opaco de nginx sin saber si el documento entró."""
client = _client(handler=lambda r: httpx.Response(200, json={}), timeout_ms=8000, upload_timeout_ms=55000)
assert client.timeout_s == 8.0
assert client.upload_timeout_s == 55.0
assert client.upload_timeout_s > client.timeout_s
def test_ensure_then_upload_puede_ramificar_por_el_code_del_404():
"""El 404 del expediente tiene que llegar al worker con su ``code`` y su ``status_code``.
Es lo que dispara el ensure-then-upload; sin el code, el worker tendría que adivinar de qué es
el 404 y crearía provisionales por cualquier ausencia.
"""
def handler(request):
return httpx.Response(
404, json={"error": {"code": "expediente_no_encontrado", "message": "no está"}}
)
with pytest.raises(EfcClientError) as exc:
_client(handler).upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x")
assert exc.value.status_code == 404
assert exc.value.code == "expediente_no_encontrado"
assert exc.value.retryable is False
def test_la_descarga_devuelve_contenido_y_nombre_del_content_disposition():
def handler(request):
return httpx.Response(
200,
content=b"contenido binario",
headers={"Content-Disposition": 'attachment; filename="factura.pdf"'},
)
contenido, nombre = _client(handler).download_documento("org-1", "doc-1")
assert contenido == b"contenido binario"
assert nombre == "factura.pdf"
def test_la_descarga_sin_content_disposition_cae_al_id_del_documento():
def handler(request):
return httpx.Response(200, content=b"x")
_contenido, nombre = _client(handler).download_documento("org-1", "doc-9")
assert nombre == "doc-9"

View File

@@ -1,350 +0,0 @@
"""Pruebas de la entrega de un archivo al expediente de EFC.
Cubren las tres cosas que hacen que este carril no pierda ni duplique archivos: el
**ensure-then-upload** cuando el provisional todavía no existe allá, el **corte directo**
(``delete_local``) que borra la copia local solo al confirmar, y la idempotencia por
``crm_document_ref`` cuando un timeout ambiguo hace reintentar.
"""
import pytest
from api.v1.modules.crm.expediente_gateway import service as gateway
from api.v1.modules.crm.expediente_gateway.models import (
FILE_KIND_DOCUMENTO,
MAX_ATTEMPTS,
SOURCE_CRM_DOCUMENTS,
STATUS_FAILED,
STATUS_PENDING,
STATUS_SENT,
EfcFileOutbox,
)
from api.v1.modules.crm.expedientes import service as expedientes_service
from api.v1.modules.crm.documents.models import Document
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
from core.efc_client import EfcClientError
from tests.conftest import COMPANY_ID, TENANT_ID
CONTENIDO = b"%PDF-1.4 guia madre"
S3_KEY = "tenants/1/companies/1/expedientes/1/guia.pdf"
class _ClienteFalso:
"""Doble del cliente de EFC. Registra qué se le pidió, para poder afirmarlo."""
is_configured = True
def __init__(self, *, upload_falla_con=None, falla_solo_la_primera=True):
self.uploads = []
self.ingests = []
self._upload_falla_con = upload_falla_con
self._falla_solo_la_primera = falla_solo_la_primera
def ingest_expediente(self, payload):
self.ingests.append(payload)
return {"status": "created", "efc": {"pedimento_id": "ped-1"}}
def upload_documento(self, org_id, company_id, expediente_id, tipo, filename, content,
content_type="application/octet-stream", crm_document_ref=None):
primera = not self.uploads
self.uploads.append({
"org_id": org_id, "company_id": company_id, "expediente_id": expediente_id,
"tipo": tipo, "filename": filename, "content": content,
"content_type": content_type, "crm_document_ref": crm_document_ref,
})
if self._upload_falla_con is not None and (primera or not self._falla_solo_la_primera):
raise self._upload_falla_con
return {"id": f"doc-{len(self.uploads)}"}
@pytest.fixture()
def entorno(db, monkeypatch):
"""EFC encendido, storage simulado y organización ya resuelta."""
from core.config import settings
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
borrados = []
import core.storage_s3 as storage
monkeypatch.setattr(storage, "get_object_bytes", lambda key: CONTENIDO)
monkeypatch.setattr(storage, "delete_object_if_exists", lambda key: borrados.append(key))
solicitud = sr_service.create_service_request(
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
)
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
return {"db": db, "expediente": expediente, "borrados": borrados}
def _documento_local(db, expediente) -> Document:
doc = Document(
doc_type="MBL",
name="guia.pdf",
file_key=S3_KEY,
content_type="application/pdf",
size_bytes=len(CONTENIDO),
expediente_id=expediente.id,
efc_sync_state="PENDING",
tenant_id=TENANT_ID,
company_id=COMPANY_ID,
)
db.add(doc)
db.flush()
doc.efc_document_ref = f"CRMDOC-{COMPANY_ID}-{doc.id}"
db.commit()
return doc
def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
row = EfcFileOutbox(
kind=FILE_KIND_DOCUMENTO,
s3_key=S3_KEY,
file_name="guia.pdf",
content_type="application/pdf",
efc_tipo="MBL",
source_table=SOURCE_CRM_DOCUMENTS,
source_id=documento.id,
crm_document_ref=documento.efc_document_ref,
expediente_ref=expediente.id,
delete_local=kwargs.pop("delete_local", True),
status=kwargs.pop("status", STATUS_PENDING),
tenant_id=TENANT_ID,
company_id=COMPANY_ID,
**kwargs,
)
db.add(row)
db.commit()
return row
# ── Camino feliz ─────────────────────────────────────────────────────────────
def test_entrega_feliz_marca_la_fila_y_el_documento(entorno):
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
cliente = _ClienteFalso()
gateway.deliver_file_row(db, row, cliente)
assert row.status == STATUS_SENT
assert row.efc_document_id == "doc-1"
assert row.sent_at is not None
assert documento.efc_sync_state == "SYNCED"
assert documento.efc_document_id == "doc-1"
assert documento.efc_synced_at is not None
def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entorno):
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
cliente = _ClienteFalso()
gateway.deliver_file_row(db, row, cliente)
assert len(cliente.uploads) == 1
subida = cliente.uploads[0]
assert subida["crm_document_ref"] == documento.efc_document_ref
assert subida["content"] == CONTENIDO
assert subida["tipo"] == "MBL"
assert subida["expediente_id"] == expediente.id
# ── Ensure-then-upload ───────────────────────────────────────────────────────
def test_un_404_de_expediente_crea_el_provisional_y_reintenta_una_vez(entorno):
"""La creación del provisional y la subida son colas distintas: la subida puede llegar antes.
Sin esto, el primer documento de cada expediente fallaría y esperaría al barrido.
"""
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
cliente = _ClienteFalso(
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado")
)
gateway.deliver_file_row(db, row, cliente)
assert len(cliente.ingests) == 1
assert cliente.ingests[0]["folio"] == expediente.folio
assert cliente.ingests[0]["storage_token"] == expediente.efc_storage_token
assert len(cliente.uploads) == 2 # el que falló + UNO de reintento
assert row.status == STATUS_SENT
def test_un_404_con_OTRO_code_no_dispara_el_ensure(entorno):
"""El ensure se dispara por el ``code``, no por el 404 a secas.
Si se disparara por cualquier 404, un documento no encontrado crearía provisionales espurios.
"""
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
cliente = _ClienteFalso(
upload_falla_con=EfcClientError("otro", status_code=404, code="documento_no_encontrado"),
falla_solo_la_primera=False,
)
gateway.deliver_file_row(db, row, cliente)
assert cliente.ingests == []
assert len(cliente.uploads) == 1
assert row.attempts == 1
def test_el_ensure_reintenta_UNA_vez_y_no_entra_en_bucle(entorno):
"""Si el reintento vuelve a dar 404, se registra el fallo. No se reintenta indefinidamente."""
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
cliente = _ClienteFalso(
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado"),
falla_solo_la_primera=False,
)
gateway.deliver_file_row(db, row, cliente)
assert len(cliente.ingests) == 1
assert len(cliente.uploads) == 2
assert row.attempts == 1
assert row.status == STATUS_FAILED # un 404 no es retryable
# ── Corte directo (delete_local) ─────────────────────────────────────────────
def test_al_confirmar_se_borra_la_copia_local(entorno):
"""«EFC es la fuente única» se cumple así: el objeto local se borra AL CONFIRMAR, no antes."""
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento, delete_local=True)
gateway.deliver_file_row(db, row, _ClienteFalso())
assert borrados == [S3_KEY]
# La key local se limpia: dejarla apuntaría a un objeto que ya no existe y la descarga se
# ramificaría por el camino equivocado.
assert documento.file_key is None
def test_con_delete_local_en_false_no_se_borra_nada(entorno):
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento, delete_local=False)
gateway.deliver_file_row(db, row, _ClienteFalso())
assert borrados == []
assert documento.file_key == S3_KEY
assert row.status == STATUS_SENT
def test_si_el_borrado_local_falla_la_entrega_sigue_siendo_valida(entorno, monkeypatch):
"""El archivo YA está en EFC. No poder borrar la copia local no invalida la entrega, y volver a
intentarla subiría el mismo documento otra vez."""
db, expediente = entorno["db"], entorno["expediente"]
import core.storage_s3 as storage
def _revienta(key):
raise RuntimeError("MinIO no responde")
monkeypatch.setattr(storage, "delete_object_if_exists", _revienta)
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento, delete_local=True)
gateway.deliver_file_row(db, row, _ClienteFalso())
assert row.status == STATUS_SENT
assert documento.efc_sync_state == "SYNCED"
def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(entorno):
"""Nunca se borra el original antes de confirmar la subida: si se borrara primero y la subida
fallara, el archivo se habría perdido."""
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento, delete_local=True)
cliente = _ClienteFalso(
upload_falla_con=EfcClientError("500", status_code=500, retryable=True),
falla_solo_la_primera=False,
)
gateway.deliver_file_row(db, row, cliente)
assert borrados == [] # la subida falló: el original sigue ahí
assert row.status == STATUS_PENDING
assert documento.file_key == S3_KEY
# ── Fallos ───────────────────────────────────────────────────────────────────
def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno):
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento, attempts=MAX_ATTEMPTS - 1)
cliente = _ClienteFalso(
upload_falla_con=EfcClientError("EFC caído", status_code=503, retryable=True),
falla_solo_la_primera=False,
)
gateway.deliver_file_row(db, row, cliente) # no lanza
assert row.status == STATUS_FAILED
assert documento.efc_sync_state == "FAILED"
assert "EFC caído" in documento.efc_error_detail
assert documento.efc_attempts == MAX_ATTEMPTS
def test_una_fila_ya_enviada_no_vuelve_a_subir_el_archivo(entorno):
"""Segunda guarda de idempotencia. Un re-despacho tras un timeout ambiguo no duplica."""
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento, status=STATUS_SENT)
cliente = _ClienteFalso()
gateway.deliver_file_row(db, row, cliente)
assert cliente.uploads == []
def test_un_expediente_borrado_deja_la_fila_reintentable(entorno):
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
row.expediente_ref = 999999
db.commit()
gateway.deliver_file_row(db, row, _ClienteFalso())
assert row.status == STATUS_PENDING # retryable: el expediente puede reaparecer
assert row.attempts == 1
def test_un_reintento_tras_timeout_manda_el_mismo_ref_y_no_duplica(entorno):
"""El ``crm_document_ref`` es estable entre reintentos: EFC devuelve 200 con el que ya existía.
Es la tercera capa de idempotencia y la que cubre el timeout ambiguo —EFC commiteó y contestó
tarde—, donde el CRM no puede saber si el documento entró.
"""
db, expediente = entorno["db"], entorno["expediente"]
documento = _documento_local(db, expediente)
row = _fila(db, expediente, documento)
primer_cliente = _ClienteFalso(
upload_falla_con=EfcClientError("timeout", retryable=True), falla_solo_la_primera=False
)
gateway.deliver_file_row(db, row, primer_cliente)
assert row.status == STATUS_PENDING
segundo_cliente = _ClienteFalso()
gateway.deliver_file_row(db, row, segundo_cliente)
assert primer_cliente.uploads[0]["crm_document_ref"] == segundo_cliente.uploads[0]["crm_document_ref"]
assert row.status == STATUS_SENT

Some files were not shown because too many files have changed in this diff Show More