Por qué el reporting nativo de Odoo no es Business Intelligence
Odoo tiene un módulo de informes razonablemente bueno para la gestión operativa del día a día: ventas del mes, facturas pendientes, movimientos de stock. Pero cuando la dirección necesita responder preguntas estratégicas —¿cuál es mi margen real por familia de producto? ¿qué cohorte de clientes tiene mejor LTV? ¿en qué semana del trimestre se concentra el 40% del cashflow?— el módulo de informes de Odoo se queda corto por diseño.
El problema no es Odoo. El problema es confundir un ERP con una plataforma analítica. Odoo está optimizado para registrar y procesar transacciones. Esa base de datos transaccional (OLTP) no está diseñada para consultas analíticas complejas que cruzan decenas de tablas, agregan millones de filas y deben responder en menos de dos segundos. Si lanzas una query analítica pesada directamente contra el PostgreSQL de producción de Odoo, estás compitiendo por recursos con los usuarios activos del ERP.
La solución es una arquitectura de datos en dos capas: Odoo como fuente de verdad transaccional y una réplica de solo lectura (o un data warehouse ligero) como base analítica, con Metabase como capa de visualización y Gobierno del dato. Este artículo describe exactamente cómo montar esa arquitectura.
Arquitectura general: réplica de solo lectura + Metabase + dbt
La arquitectura que recomendamos para PYMEs y medianas empresas tiene tres componentes:
- Réplica PostgreSQL de solo lectura (streaming replication desde el primario de Odoo). Las consultas analíticas van aquí, nunca al primario.
- dbt (data build tool) corriendo sobre esa réplica o sobre un esquema analítico separado. dbt transforma el esquema de Odoo —complejo, con decenas de tablas normalizadas— en modelos semánticos limpios y documentados que los analistas pueden usar sin conocer el modelo de datos interno de Odoo.
- Metabase conectado a esos modelos dbt. Metabase es open source, se despliega en Docker en minutos, y su interfaz permite que cualquier persona de dirección construya sus propias preguntas sin SQL.
┌─────────────────────────────────────┐
│ Odoo (producción) │
│ PostgreSQL primario (R/W) │
└──────────────┬──────────────────────┘
│ streaming replication
┌──────────────▼──────────────────────┐
│ PostgreSQL réplica (solo lectura) │
│ schema: public (tablas Odoo) │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ dbt (transforma esquema Odoo) │
│ schema: analytics │
│ modelos: dim_*, fct_*, mart_* │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Metabase (open source) │
│ Dashboards, alertas, email reports │
└─────────────────────────────────────┘
Paso 1: Configurar la réplica de solo lectura de PostgreSQL
Si ya tienes Patroni o replicación streaming configurada (como se describe en el artículo sobre alta disponibilidad), simplemente usar el puerto de réplica (5433 vía HAProxy) para las conexiones de Metabase. Si partes de cero y quieres una réplica sencilla solo para analítica:
# En el servidor réplica — postgresql.conf
primary_conninfo = 'host=10.0.1.11 port=5432 user=replicator password=<PASSWORD> application_name=analytics_replica'
hot_standby = on
hot_standby_feedback = on # evita que el primario limpie filas que la réplica todavía necesita
max_standby_streaming_delay = 30s
# Crear el fichero de señal de standby
touch /var/lib/postgresql/16/main/standby.signal
# En el primario — pg_hba.conf, añadir:
host replication replicator 10.0.1.20/32 scram-sha-256
Una vez activa la réplica, crear un usuario de solo lectura para Metabase con acceso únicamente al esquema analítico:
-- En la réplica, conectado como superusuario:
CREATE ROLE metabase_ro WITH LOGIN PASSWORD '<PASSWORD_SEGURO>';
GRANT CONNECT ON DATABASE odoo_prod TO metabase_ro;
GRANT USAGE ON SCHEMA analytics TO metabase_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO metabase_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT SELECT ON TABLES TO metabase_ro;
-- NO dar acceso al schema public (tablas brutas de Odoo) desde Metabase
Paso 2: Instalar Metabase con Docker Compose
Metabase se despliega en minutos. La configuración mínima de producción usa su propia base de datos (PostgreSQL preferiblemente, no H2) para almacenar dashboards, usuarios y preguntas guardadas:
# docker-compose.metabase.yml
version: '3.8'
services:
metabase:
image: metabase/metabase:v0.50.0
container_name: metabase
restart: unless-stopped
ports:
- "3000:3000"
environment:
MB_DB_TYPE: postgres
MB_DB_HOST: metabase-db
MB_DB_PORT: 5432
MB_DB_DBNAME: metabase
MB_DB_USER: metabase
MB_DB_PASS: <METABASE_DB_PASSWORD>
MB_SITE_URL: https://bi.tuempresa.com
MB_EMAIL_SMTP_HOST: smtp.tuempresa.com
MB_EMAIL_SMTP_PORT: 587
MB_EMAIL_SMTP_USERNAME: alertas@tuempresa.com
MB_EMAIL_SMTP_PASSWORD: <SMTP_PASSWORD>
MB_EMAIL_SMTP_SECURITY: starttls
JAVA_TIMEZONE: Europe/Madrid
depends_on:
- metabase-db
metabase-db:
image: postgres:16-alpine
container_name: metabase-db
restart: unless-stopped
environment:
POSTGRES_DB: metabase
POSTGRES_USER: metabase
POSTGRES_PASSWORD: <METABASE_DB_PASSWORD>
volumes:
- metabase_db_data:/var/lib/postgresql/data
volumes:
metabase_db_data:
Poner Nginx delante de Metabase con SSL (Certbot) antes de exponerlo a Internet. Metabase en puerto 3000 nunca debe ser accesible directamente desde fuera.
Paso 3: Modelar el esquema Odoo con dbt
El esquema de PostgreSQL de Odoo es complejo: más de 400 tablas con nombres crípticos (account_move, stock_quant, sale_order_line). dbt permite crear vistas y tablas materializadas limpias sobre ese esquema, con documentación, tests de calidad de datos y linaje automático.
Estructura de proyecto dbt recomendada para Odoo
odoo_analytics/
├── models/
│ ├── staging/ # tablas brutas de Odoo, renombradas y tipadas
│ │ ├── stg_sale_orders.sql
│ │ ├── stg_invoices.sql
│ │ ├── stg_products.sql
│ │ └── stg_partners.sql
│ ├── intermediate/ # joins y cálculos intermedios
│ │ ├── int_order_lines_enriched.sql
│ │ └── int_invoice_lines_enriched.sql
│ └── marts/ # modelos finales para Metabase
│ ├── mart_sales.sql
│ ├── mart_margin.sql
│ ├── mart_cashflow.sql
│ └── mart_customer_cohorts.sql
├── tests/
├── macros/
└── dbt_project.yml
Modelo de staging: pedidos de venta
-- models/staging/stg_sale_orders.sql
-- {{ config(materialized='view') }}
SELECT
so.id AS order_id,
so.name AS order_ref,
so.date_order::date AS order_date,
so.state AS order_state,
rp.name AS customer_name,
rp.id AS customer_id,
rc.name AS customer_country,
so.amount_untaxed AS amount_net,
so.amount_tax AS amount_tax,
so.amount_total AS amount_total,
so.currency_id,
su.name AS salesperson,
so.team_id,
so.company_id
FROM public.sale_order so
JOIN public.res_partner rp ON rp.id = so.partner_id
LEFT JOIN public.res_country rc ON rc.id = rp.country_id
LEFT JOIN public.res_users su ON su.id = so.user_id
WHERE so.state IN ('sale', 'done')
Modelo de mart: margen por producto
-- models/marts/mart_margin.sql
-- {{ config(materialized='table') }}
WITH order_lines AS (
SELECT
sol.order_id,
so.order_date,
so.customer_id,
so.customer_name,
sol.product_id,
pt.name AS product_name,
pc.name AS product_category,
sol.product_uom_qty AS qty_sold,
sol.price_unit AS sale_price,
sol.price_subtotal AS revenue,
COALESCE(pp.standard_price, 0) AS cost_price,
(sol.price_subtotal - sol.product_uom_qty * COALESCE(pp.standard_price, 0)) AS gross_margin
FROM public.sale_order_line sol
JOIN public.sale_order so ON so.id = sol.order_id
JOIN public.product_product pp ON pp.id = sol.product_id
JOIN public.product_template pt ON pt.id = pp.product_tmpl_id
LEFT JOIN public.product_category pc ON pc.id = pt.categ_id
WHERE so.state IN ('sale', 'done')
AND sol.product_id IS NOT NULL
)
SELECT
order_date,
DATE_TRUNC('month', order_date) AS month,
DATE_TRUNC('quarter', order_date) AS quarter,
product_id,
product_name,
product_category,
customer_id,
customer_name,
SUM(qty_sold) AS total_qty,
SUM(revenue) AS total_revenue,
SUM(cost_price * qty_sold) AS total_cost,
SUM(gross_margin) AS total_margin,
CASE WHEN SUM(revenue) > 0
THEN ROUND(SUM(gross_margin) / SUM(revenue) * 100, 2)
ELSE 0
END AS margin_pct
FROM order_lines
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8
Ejecutar dbt con un cron diario (o cada hora para datos más frescos):
# crontab -e
0 6 * * * cd /opt/odoo_analytics && dbt run --profiles-dir . --target prod >> /var/log/dbt/run.log 2>&1
30 6 * * * cd /opt/odoo_analytics && dbt test --profiles-dir . --target prod >> /var/log/dbt/test.log 2>&1
Paso 4: KPIs ejecutivos que realmente importan
Una vez los modelos dbt están disponibles en Metabase, los dashboards ejecutivos que más valor aportan en el contexto de una PYME con Odoo son:
Dashboard 1: Ventas y pipeline
- Ventas del mes actual vs. mismo mes del año anterior (variación %)
- Ventas acumuladas del año vs. objetivo
- Top 10 clientes por facturación en los últimos 90 días
- Pipeline de presupuestos abiertos (estado
draft/sentensale_order) con probabilidad ponderada - Tasa de conversión presupuesto → pedido confirmado por comercial
Dashboard 2: Margen y rentabilidad
- Margen bruto por familia de producto (%) — mapa de calor mensual
- Evolución del margen medio de los últimos 12 meses
- Productos con margen negativo o por debajo del umbral (alerta automática)
- Contribución de cada categoría al margen total (stacked bar)
Dashboard 3: Cashflow operativo
-- Cashflow semanal: cobros esperados vs. pagos previstos
SELECT
DATE_TRUNC('week', am.invoice_date_due) AS week,
am.move_type,
SUM(am.amount_residual_signed) AS pending_amount
FROM public.account_move am
WHERE am.state = 'posted'
AND am.payment_state IN ('not_paid', 'partial')
AND am.invoice_date_due BETWEEN CURRENT_DATE AND CURRENT_DATE + 90
GROUP BY 1, 2
ORDER BY 1, 2
Dashboard 4: Cohortes de clientes
El análisis de cohortes es quizás el KPI más infrautilizado en PYMEs con Odoo. Permite responder: ¿los clientes que captamos en Q1 2025 siguen comprando a los 6 meses al mismo ritmo que los de Q1 2024?
-- Cohorte de retención mensual
WITH first_order AS (
SELECT
partner_id,
MIN(DATE_TRUNC('month', date_order)) AS cohort_month
FROM public.sale_order
WHERE state IN ('sale', 'done')
GROUP BY 1
),
orders AS (
SELECT
so.partner_id,
DATE_TRUNC('month', so.date_order) AS order_month
FROM public.sale_order so
WHERE so.state IN ('sale', 'done')
GROUP BY 1, 2
)
SELECT
fo.cohort_month,
o.order_month,
EXTRACT(MONTH FROM AGE(o.order_month, fo.cohort_month)) AS months_since_first,
COUNT(DISTINCT o.partner_id) AS active_customers
FROM first_order fo
JOIN orders o ON o.partner_id = fo.partner_id
AND o.order_month >= fo.cohort_month
GROUP BY 1, 2, 3
ORDER BY 1, 3
Paso 5: Reportes automáticos por email y alertas
Metabase incluye un sistema de pulses y reportes programados que permite enviar dashboards por email automáticamente. La configuración recomendada para dirección:
- Lunes 08:00: resumen semanal de ventas, margen y cobros pendientes de la semana
- Día 1 de cada mes 07:00: cierre del mes anterior con comparativa vs. mes anterior y mismo mes del año pasado
- Alerta en tiempo real: si el margen de cualquier línea de pedido cae por debajo del umbral definido (configurable por familia de producto)
- Alerta de cashflow: si el saldo de cobros esperados en los próximos 30 días cae por debajo de X euros
Las alertas de Metabase se configuran desde la interfaz (sección Alertas en cualquier pregunta guardada) y se envían por email o webhook. Para alertas en Telegram o Slack, un webhook de Metabase apuntando a un bot de Telegram es la solución más directa.
Gobierno del dato: el elemento que más proyectos ignoran
Una plataforma de BI sin gobierno del dato se convierte en un caos de dashboards contradictorios en tres meses. Las preguntas que matan la confianza en el dato son: ¿por qué este dashboard dice que vendimos 450k y el de comercial dice 430k? La respuesta siempre es: definiciones inconsistentes. Para evitarlo:
- Definir las métricas en dbt, no en Metabase: la definición de “venta confirmada” (qué estados de
sale_order, si se incluyen devoluciones, si se cuenta en fecha de pedido o de factura) debe estar en el modelo dbt, no en cada dashboard por separado. - Tests de dbt para calidad de datos: verificar que no hay
order_idnulos, que los importes son siempre positivos, que cada factura apunta a un cliente existente. Si el test falla, el modelo no se materializa y Metabase muestra los datos del día anterior. - Documentación en dbt: cada modelo y cada columna tiene una descripción en
schema.yml. Metabase puede importar esa documentación y mostrarla al usuario al hacer hover sobre una columna. - Control de acceso en Metabase: dirección ve todos los dashboards; comerciales ven solo sus propias métricas; logística no ve márgenes. Los grupos y permisos de Metabase mapean exactamente sobre los roles de negocio.
Costes y alternativas
| Componente | Opción open source | Alternativa comercial | Coste aprox. |
|---|---|---|---|
| Visualización | Metabase OSS | Metabase Pro / Power BI | 0€ / desde 500€/mes |
| Transformación | dbt Core | dbt Cloud | 0€ / desde 100€/mes |
| Réplica BBDD | PostgreSQL streaming | AWS RDS read replica | 0€ (self-hosted) / desde 80€/mes |
| Orquestación | cron + dbt | Airflow / Dagster | 0€ / coste de infraestructura |
Para la mayoría de PYMEs españolas con Odoo, el stack réplica PG + dbt Core + Metabase OSS es suficiente y el coste de infraestructura adicional es de 30-80€/mes (un VPS pequeño para Metabase y dbt). La inversión real es el tiempo de modelado inicial: normalmente entre 15 y 40 horas de consultoría técnica dependiendo de la complejidad del negocio.
Errores frecuentes en proyectos BI sobre Odoo
- Conectar Metabase directamente al PostgreSQL de producción: las queries analíticas compiten con Odoo por CPU y conexiones. Siempre usar réplica o datos exportados.
- Construir dashboards antes de modelar el dato: el resultado son dashboards que nadie entiende ni mantiene. Primero el modelo dbt, luego el dashboard.
- Ignorar los datos de coste (
standard_price): sin costes, los dashboards de margen son una ilusión. Verificar que el equipo de compras mantiene los costes actualizados en Odoo. - No programar los reportes automáticos: si el dashboard no llega al email de dirección, dirección no lo usa. La automatización es lo que convierte el proyecto en un hábito.