Skip to Content

El Grupo Compañía: Cómo Organizar Clientes Multi-Empresa en Odoo (con la Fuerza de tu Lado)

Una Nueva Esperanza — El Problema
June 12, 2026 by
BloVox

Que la Fuerza del filtro te acompañe.


Una Nueva Esperanza — El Problema

Imaginá por un momento que sos una consultora tecnológica (llamémosla Alianza Rebelde Consulting). Tus clientes no son simples empresas unifamiliares. Son corporaciones, holdings, grupos empresariales con tentáculos que se extienden por múltiples subsidiarias, cada una con su propio equipo, sus propios proyectos, sus propios empleados, y sus propias facturas.

Llega un día un cliente nuevo: la Corporación Nova. No es una empresa. Son tres.

  • EcoTech S.A. — La estrella de la muerte verde: soluciones de energía renovable.
  • Grupo Ambiental — Consultoría en sustentabilidad, con 50 empleados.
  • NovaTech — Desarrollo de software para monitoreo ambiental.

Tres empresas distintas. Un solo grupo. Un solo contrato marco. Una sola factura consolidada (bueno, tres, pero vos me entendés).

Y vos, como consultora, tenés que:

  1. Registrar horas de consultoría contra estas empresas.
  2. Asignar tareas y proyectos que cruzan las subsidiarias.
  3. Facturar correctamente.
  4. Y lo más importante: cuando el director de la Corporación Nova te pida "decime todas las horas que le dedicamos al grupo este mes", poder responder sin tener que hacer 17 filtros manuales, ni copiar 3 IDs de empresas, ni rezar para que no se te escape ninguna línea.

El problema, en una frase: los filtros de Odoo no están diseñados para agrupar compañías.

Cuando abrís el módulo de Timesheets y querés ver las horas de todo el grupo, te encontrás con un filtro de "Compañía" que te deja elegir una sola: EcoTech, Grupo Ambiental o NovaTech. No hay un "mostrame todo el grupo" porque, para Odoo, son entidades separadas.

Y si trabajás con contactos de esas empresas, peor todavía. Porque un contacto (digamos, Juan Pérez, CFO de EcoTech) está vinculado a EcoTech a través del parent_id. Y si Juan Pérez tiene una línea de timesheet, el partner_id apunta a Juan Pérez, no a EcoTech. Para llegar a la compañía tenés que navegar partner_id.parent_id. Y si querés filtrar por grupo... no hay campo.

Esto no es un bug de Odoo. Es una limitación de diseño. Odoo modela compañías como entidades independientes. Y está bien para el 80% de los casos. Pero cuando trabajás con grupos empresariales, el modelo se queda corto.

¿La solución? No, no es un hiperimpulsor. Es un campo custom que vas a agregar a res.partner: el company_group_id.

Y sí, suena a nombre genérico de personaje de Star Wars. Pero te prometo que es mucho más útil que Jar Jar Binks.


El Contexto Real (Anonimizado pero Verídico)

Esto que estoy contando no es un ejercicio teórico. Surge de un caso real de una consultora tecnológica —llamémosla Alianza Rebelde Consulting para proteger su identidad— que enfrentó exactamente este problema.

El cliente era un holding de servicios ambientales con 7 empresas subsidiarias, cada una con entre 5 y 50 empleados. El holding contrataba consultoría para proyectos transversales: implementación de un ERP, consultoría de procesos, desarrollo de software a medida. El desafío era que un mismo consultor podía cargar horas contra distintas empresas del grupo en la misma semana: el lunes trabajaba en un proyecto de EcoTech, el martes en una capacitación para Grupo Ambiental, el miércoles en una revisión de código para NovaTech.

Para el director del holding, eso era una sola relación comercial. Para Odoo, eran tres clientes distintos. Y el equipo de finanzas necesitaba consolidar todo a fin de mes sin volverse loco.

La solución que encontraron fue exactamente la que vamos a desarrollar acá: un campo de agrupación de compañías y un dominio multi-ruta que captura todas las horas, tareas y ventas del grupo sin importar cómo se relacionen los datos.


¿Qué es un Grupo Empresarial en Términos de Odoo?

Antes de meternos en código, asegurémonos de que estamos en la misma página conceptual.

En el mundo real, un grupo empresarial es una estructura legal donde una empresa controla a otras (subsidiarias) mediante participación accionaria. Las subsidiarias tienen su propia personalidad jurídica, su propio CUIT/RUT/NIT, sus propias facturas. Pero operan bajo una dirección estratégica unificada.

En Odoo, esto se traduce como múltiples registros en res.partner con is_company = True. Cada uno es una compañía independiente. Pueden estar vinculadas por parent_id (compañía madre → subsidiaria), pero esa relación está pensada más para jerarquías de contacto que para agrupar empresas hermanas.

Acá está la clave: tener un parent_id común no resuelve el problema, porque:

  • parent_id vincula a un contacto con su compañía. No a una compañía con su holding.
  • Si dos compañías están bajo el mismo holding, no hay un parent_id compartido que las una (a menos que el holding sea una compañía y las subsidiarias sean contactos, pero eso no funciona porque las subsidiarias son compañías, no contactos).
  • parent_id está diseñado para la jerarquía

El Imperio Contraataca — Los Problemas Reales

Antes de meternos en la solución técnica, analicemos con precisión milimétrica (como un cazarrecompensas calculando su pago) los problemas que enfrentás sin un campo de agrupación.

Problema 1: El Filtro Manual del Sufrimiento

Querés ver las horas de todo el grupo Nova de enero. Abrís Timesheets. Ponés filtro: Compañía = EcoTech. Después de unos segundos cargan los datos. Copiás a Excel. Volvés a Odoo. Cambiás filtro: Compañía = Grupo Ambiental. Repetís. Cambiás filtro: Compañía = NovaTech. Repetís. Después tenés que unificar todo en una planilla, y cruzar los dedos para que no haya diferencias.

Si tenés 3 empresas, perdés 5 minutos. Si tenés 10, perdés media hora. Si tenés 30, ya valió más externalizar el análisis a un droide.

Problema 2: El Hardcodeo de IDs (el Lado Oscuro)

"Bueno — decís — me hago un dominio personalizado." Abrís los filtros, seleccionás "Dominio avanzado", y escribís:

[("partner_id.parent_id", "in", [id_ecotech, id_ambiental, id_novatech])]

Funciona. Pero:

  • ¿Qué pasa cuando el grupo incorpora una nueva subsidiaria? Tenés que acordarte de editar el filtro.
  • ¿Qué pasa si otro usuario quiere el mismo filtro? Lo tiene que reescribir.
  • ¿Qué pasa cuando eliminás un partner y el ID ya no existe? El filtro se rompe en silencio.

Hardcodear IDs es como construir la Estrella de la Muerte con un plano que solo existe en la cabeza de un ingeniero: en algún momento va a explotar.

Problema 3: La Paradoja del Contacto

Odoo permite que los contactos (personas) estén asociados a una compañía mediante parent_id. Pero también permite que los contactos tengan una compañía propia (company_id) o ninguna. Esto crea una telaraña de relaciones:

  • Una account.analytic.line (timesheet) tiene un partner_id. Ese partner_id puede ser una compañía (EcoTech) o una persona (Juan Pérez).
  • Si es una persona, su compañía madre es partner_id.parent_id.
  • Si la persona no tiene parent_id, la línea no se vincula a ninguna compañía.
  • El project_id también tiene un partner_id, que puede ser la compañía o no.
  • La sale.order.line tiene un order_id.partner_id.
  • La factura tiene un invoice_partner_id.

Para capturar TODO el universo de líneas que pertenecen a un grupo, no alcanza con una sola ruta. Necesitás, como mínimo, 6 o 7 caminos distintos para cubrir todas las combinaciones posibles. Y cada uno de esos caminos tiene que terminar en el company_group_id.

Suena a que estás construyendo una red de alcantarillado de la Estrella de la Muerte II. Y en cierta forma, lo estás haciendo.

Problema 4: El Mapa Estelar Incompleto

Cuando un cliente te pide "todas las horas de consultoría del grupo", el dominio no termina en el módulo de Timesheets. También necesitás:

  • Filtrar tareas de proyecto por grupo.
  • Filtrar proyectos por grupo.
  • Filtrar líneas de órdenes de venta por grupo.
  • Filtrar facturas por grupo.
  • Filtrar tickets de soporte por grupo.

Cada módulo tiene su propia estructura de datos, y cada uno requiere un mapeo distinto. Si no tenés un campo de agrupación central, vas a terminar con 30 filtros distintos, cada uno con sus IDs hardcodeados, cada uno con sus probabilidades de fallar.

Es el Imperio contraatacando. Y está ganando.


El Despertar del Grupo Compañía — La Solución

Paso 1: El Campo Custom (Que no es tan Custom)

Lo primero: necesitás un campo en res.partner que sirva como "agrupador" de compañías. Lo llamamos company_group_id. No existe en Odoo estándar (salvo contadas excepciones que vamos a ver). Es un campo Many2one a res.partner, que apunta a una compañía "cabecera" del grupo.

⚠️ ADVERTENCIA IMPORTANTE ⚠️

El campo company_group_id no forma parte del módulo base de Odoo. Es un campo custom, implementado en módulos como partner_company_group de la OCA (Odoo Community Association), o desarrollado a medida para tu implementación.

Antes de seguir, verificá si tu instancia lo tiene:

  1. Andá a Ajustes > Técnico > Modelos de base de datos.
  2. Buscá res.partner.
  3. En la lista de campos, buscá company_group_id.
  4. Si no aparece, necesitás instalarlo o crearlo.

Si estás en Odoo.sh o una instancia estándar sin módulos adicionales, company_group_id no existe. Tenés dos opciones:

  • Instalar el módulo partner_company_group de la OCA (si está disponible).
  • Crear un módulo custom con el campo.

Para los fines de este artículo, asumimos que el campo existe y está configurado. Si no lo tenés, considerá esta sección como "lo que la Fuerza te puede conceder si trabajás en ello".

Paso 2: La Configuración

Una vez que tenés el campo, la configuración es sencilla pero requiere disciplina:

  1. Creá una compañía "cabecera" para el grupo. Por ejemplo, "Corporación Nova" con tipo Compañía (is_company = True).
  2. En cada compañía del grupo (EcoTech, Grupo Ambiental, NovaTech), seteá el campo company_group_id apuntando a "Corporación Nova".
  3. Opcional: también podés setear company_group_id en los contactos (Juan Pérez, María García), pero no es necesario si ya lo tienen sus compañías madre.

El resultado en la base de datos se ve así:

res.partner
────────────────────────────────────────────────────
| id | name              | is_company | parent_id | company_group_id |
|────|───────────────────|────────────|───────────|──────────────────|
| 10 | Corp. Nova        | True       | NULL      | NULL             |
| 11 | EcoTech S.A.      | True       | NULL      | 10               |
| 12 | Grupo Ambiental   | True       | NULL      | 10               |
| 13 | NovaTech          | True       | NULL      | 10               |
| 14 | Juan Pérez        | False      | 11        | NULL             |
| 15 | María García      | False      | 12        | NULL             |
| 16 | Pedro López       | False      | 13        | NULL             |
────────────────────────────────────────────────────

Nota crucial: parent_id vincula un contacto con su compañía madre. company_group_id vincula una compañía con su grupo. Son conceptos ortogonales y no se superponen. De hecho, los contactos normalmente NO necesitan company_group_id porque se accede a su grupo vía partner_id.parent_id.company_group_id.

Paso 3: La Ventaja de la Fuerza

Con esta estructura, ahora podés filtrar por grupo sin saber los IDs individuales. Simplemente elegís "Corporación Nova" en tu filtro de grupo, y el dominio resuelve todo:

# Encontrar todas las compañías del grupo Nova
group_partners = self.env['res.partner'].search([
    ('company_group_id', '=', corp_nova_id)
])
# → [EcoTech, Grupo Ambiental, NovaTech]

Y después usás esos IDs en los filtros del dominio.

Pero esperá, hay más. Porque vos no querés hacer dos consultas. Querés que el dominio lo haga todo en una sola sentencia, usando dot-notation. Y para eso necesitamos...


El Retorno del Jedi del Filtro — Los Dominios Completos

Clase Magistral de Dot-Notation

Antes de escribir los dominios, asegurémonos de entender cómo funciona la dot-notation en los dominios de Odoo.

Cuando escribís:

[("partner_id.parent_id.company_group_id", "=", grupo_id)]

Odoo traduce eso a SQL como un JOIN múltiple:

SELECT al.*
FROM account_analytic_line al
JOIN res_partner partner ON al.partner_id = partner.id
LEFT JOIN res_partner parent ON partner.parent_id = parent.id
WHERE parent.company_group_id = grupo_id

La dot-notation te permite navegar relaciones Many2one hacia adelante. Es como la Fuerza: te conecta con objetos que están a distancia. Pero tiene un límite: podés navegar hacia adelante (de la línea al partner, del partner al parent), pero no hacia atrás (del partner a las líneas). Para eso tenés los dominios inversos tipo [("analytic_line_ids.partner_id", "=", x)], pero eso corre en el servidor, no en la base de datos.

Regla de oro: la dot-notation funciona para relaciones Many2one, no para One2many ni Many2many en el lado izquierdo del operador.

El Operador '|' (OR Lógico)

Los dominios de Odoo usan notación polaca (prefija) para los operadores lógicos. Esto es clave porque nuestro dominio va a tener un montón de condiciones OR.

Recordatorio rápido:

# AND implícito (por defecto)
[("campo1", "=", "a"), ("campo2", "=", "b")]
# = campo1 = 'a' AND campo2 = 'b'

# OR explícito
["|", ("campo1", "=", "a"), ("campo2", "=", "b")]
# = campo1 = 'a' OR campo2 = 'b'

# OR con 3 condiciones
["|", ("campo1", "=", "a"), "|", ("campo2", "=", "b"), ("campo3", "=", "c")]
# = campo1 = 'a' OR (campo2 = 'b' OR campo3 = 'c')

El operador '|' aplica a las DOS condiciones que le siguen. Para tener OR entre muchas condiciones, se van anidando como una cebolla espacial. Es medio enredado al principio, pero es poderoso.

#### Visualicemos la Estructura

Si tenés 4 condiciones conectadas con OR, la estructura se ve así:

Dominio: ["|", A, "|", B, "|", C, D]

          OR
         /  \
        A    OR
            /  \
           B    OR
               /  \
              C    D

Cada '|' toma los dos elementos que le siguen y hace OR entre ellos. El resultado se convierte en un elemento para el '|' anterior. Es como las muñecas rusas, pero con operadores lógicos.

La regla práctica es: poné un '|' por cada condición de más que tengas. Si tenés N condiciones OR, necesitás N-1 pipes. En nuestro caso, 11 condiciones necesitan 10 pipes.

#### ¿Y AND + OR Combinados?

Si necesitás mezclar AND y OR (y en la vida real, siempre necesitás), la cosa se complica:

# Queremos: (A OR B) AND (C OR D)
# En notación polaca:
["&", "|", A, B, "|", C, D]

O, más común en nuestros dominios:

# Queremos: (A OR B OR C) AND (fecha >= X) AND (empleado = Y)
[
    "&",
        "|", A, "|", B, C,
    "&",
        ("date", ">=", fecha_inicio),
        ("employee_id", "=", empleado_id),
]

Pero para nuestro caso, todas las rutas del grupo deben estar en OR (cualquiera puede capturar la línea), y después se combinan con AND implícito con los filtros adicionales (fecha, proyecto, empleado). Odoo hace AND implícito entre todos los elementos de la lista, salvo que uses '&' explícito.

Entonces, nuestro dominio completo para un reporte real sería:

full_domain = [
    # Filtro de fecha (se combina con AND implícito)
    ("date", ">=", "2026-01-01"),
    ("date", "<=", "2026-01-31"),
    # OR de todas las rutas del grupo
    "|",
        ("partner_id.company_group_id", "=", group_id),
    "|",
        ("partner_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("task_id.partner_id.company_group_id", "=", group_id),
    "|",
        ("task_id.partner_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("project_id.partner_id.company_group_id", "=", group_id),
    "|",
        ("project_id.partner_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("so_line.order_id.partner_id.company_group_id", "=", group_id),
    "|",
        ("so_line.order_id.partner_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("invoice_partner_id.company_group_id", "=", group_id),
        ("invoice_partner_id.parent_id.company_group_id", "=", group_id),
]

Acá las condiciones de fecha están primero (AND implícito con el resto del dominio), y después viene el bloque de OR. El resultado SQL sería algo como:

WHERE al.date >= '2026-01-01'
  AND al.date <= '2026-01-31'
  AND (
      p.company_group_id = 10
      OR p.parent_id.company_group_id = 10
      OR t.partner_id.company_group_id = 10
      OR ...
  )

Perfecto. Filtrás por fecha Y por grupo.

El Mapa Estelar de Rutas

Vamos a crear el dominio definitivo. Pero primero, entendamos las rutas posibles desde una account.analytic.line hasta el company_group_id.

Ruta¿Qué captura?
partner_id.company_group_idLíneas cuyo partner es directamente una compañía del grupo
partner_id.parent_id.company_group_idLíneas cuyo partner es un contacto, y su compañía madre está en el grupo
task_id.partner_id.company_group_idLíneas vinculadas a una tarea cuyo partner es una compañía del grupo
task_id.partner_id.parent_id.company_group_idLíneas vinculadas a una tarea cuyo partner es un contacto de una compañía del grupo
project_id.partner_id.company_group_idLíneas vinculadas a un proyecto cuyo partner es una compañía del grupo
project_id.partner_id.parent_id.company_group_idLíneas vinculadas a un proyecto cuyo partner es un contacto de una compañía del grupo
so_line.order_id.partner_id.company_group_idLíneas vinculadas a una línea de venta, cuyo pedido tiene un partner que es compañía del grupo
so_line.order_id.partner_id.parent_id.company_group_idÍdem pero el partner es un contacto
invoice_partner_id.company_group_idLíneas facturables (o con invoice partner) que son compañía del grupo
invoice_partner_id.parent_id.company_group_idÍdem pero el partner es un contacto

Cada ruta necesita una condición en el dominio. Y todas se conectan con OR porque cualquiera de ellas puede ser la que capture una línea determinada.

El Dominio Definitivo (v1)

Aquí está, el dominio que va a hacer que tus timesheets cobren vida:

company_group_id = 10  # ID de "Corporación Nova"

domain = [
    "|",
        ("partner_id.company_group_id", "=", company_group_id),
    "|",
        ("partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("task_id.partner_id.company_group_id", "=", company_group_id),
    "|",
        ("task_id.partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("project_id.partner_id.company_group_id", "=", company_group_id),
    "|",
        ("project_id.partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("so_line.order_id.partner_id.company_group_id", "=", company_group_id),
    "|",
        ("so_line.order_id.partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("invoice_partner_id.company_group_id", "=", company_group_id),
        ("invoice_partner_id.parent_id.company_group_id", "=", company_group_id),
]

Pero esperá, hay un problema sutil. Si el company_group_id está seteado en las compañías del grupo pero NO en la cabecera, este dominio funciona perfecto. Pero si también seteas company_group_id en la cabecera (apuntando a sí misma o a NULL), y además la cabecera no es una compañía operativa, no va a generar líneas falsas.

El tema es: la cabecera del grupo ("Corporación Nova") es un res.partner con is_company=True, pero rara vez tiene proyectos, tareas o líneas de timesheet propias. Son las subsidiarias las que operan. Por lo tanto, el dominio rara vez va a encontrar coincidencias directas con la cabecera, y cuando las encuentre, serán casos legítimos.

El Dominio Definitivo (v2, Optimizado para Éter)

Ahora, una mejora. ¿Qué pasa si el grupo también incluye contacto directos (no vinculados a ninguna compañía madre) pero con company_group_id seteado directamente? Por ejemplo, un consultor externo que trabaja para todo el grupo.

Agregamos dos rutas más:

# Contacto directo del grupo (sin compañía madre)
"|",
    ("partner_id.company_group_id", "=", company_group_id),
# ... el resto igual, pero agregando:
"|",
    ("task_id.partner_id.company_group_id", "=", company_group_id),

En realidad la ruta partner_id.company_group_id ya está cubierta al principio. Si el contacto tiene company_group_id directamente, la primera condición lo atrapa.

Versión Final del Dominio

company_group_id = 10

domain = [
    "|",
        ("partner_id.company_group_id", "=", company_group_id),
    "|",
        ("partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("task_id.partner_id.company_group_id", "=", company_group_id),
    "|",
        ("task_id.partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("project_id.partner_id.company_group_id", "=", company_group_id),
    "|",
        ("project_id.partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("so_line.order_id.partner_id.company_group_id", "=", company_group_id),
    "|",
        ("so_line.order_id.partner_id.parent_id.company_group_id", "=", company_group_id),
    "|",
        ("invoice_partner_id.company_group_id", "=", company_group_id),
        ("invoice_partner_id.parent_id.company_group_id", "=", company_group_id),
]

11 condiciones. 10 pipes (|). Un solo dominio.

Esto magnifico. Y funciona.

Cómo lo Usás en la Práctica

#### Opción A: Filtro Favorito Guardado

  1. Andá a Facturación > Timesheets > Líneas de horas.
  2. En la barra de búsqueda, seleccioná "Filtros avanzados" (o el ícono de embudo).
  3. Elegí "Dominio personalizado".
  4. Copiá y pegá el dominio de arriba (cambiando company_group_id = 10 por el ID real de tu grupo cabecera).
  5. Aplicá el filtro.
  6. Hacé clic en "Guardar filtro actual".
  7. Ponele nombre: "Horas - Grupo Nova" y seleccioná "Compartido con todos los usuarios" si querés que todo el equipo lo vea.

¡Listo! Ahora cualquier usuario puede seleccionar "Horas - Grupo Nova" de la lista de filtros y obtener todas las horas del grupo en un instante.

#### Opción B: Botón o Acción en el Modelo

Si querés ser más elegante (y menos propenso a errores), podés crear una acción de servidor o un botón inteligente que aplique el dominio automáticamente para el grupo seleccionado.

class ResPartner(models.Model):
    _inherit = 'res.partner'

    def action_view_group_timesheets(self):
        self.ensure_one()
        group_id = self.id if self.company_group_id else self.company_group_id.id
        if not group_id:
            return

        domain = self._build_company_group_domain(group_id)
        return {
            'type': 'ir.actions.act_window',
            'name': f'Timesheets - {self.name}',
            'res_model': 'account.analytic.line',
            'view_mode': 'tree,form,graph,pivot',
            'domain': domain,
            'context': {
                'search_default_group_by': 'project_id',
                'group_by': [],
            },
        }

    def _build_company_group_domain(self, group_id):
        """Construye el dominio multi-ruta para filtrar por grupo compañía."""
        return [
            "|",
                ("partner_id.company_group_id", "=", group_id),
            "|",
                ("partner_id.parent_id.company_group_id", "=", group_id),
            "|",
                ("task_id.partner_id.company_group_id", "=", group_id),
            "|",
                ("task_id.partner_id.parent_id.company_group_id", "=", group_id),
            "|",
                ("project_id.partner_id.company_group_id", "=", group_id),
            "|",
                ("project_id.partner_id.parent_id.company_group_id", "=", group_id),
            "|",
                ("so_line.order_id.partner_id.company_group_id", "=", group_id),
            "|",
                ("so_line.order_id.partner_id.parent_id.company_group_id", "=", group_id),
            "|",
                ("invoice_partner_id.company_group_id", "=", group_id),
                ("invoice_partner_id.parent_id.company_group_id", "=", group_id),
        ]

Y después vinculás este método a un botón en la vista de formulario del partner:

<button name="action_view_group_timesheets"
        type="object"
        string="Ver Timesheets del Grupo"
        class="btn-primary"
        groups="analytic.group_analytic_accounting"/>

Justo antes del botón de "Acción", en la vista de formulario del partner. Así cualquier usuario, desde la ficha del grupo, puede ver todas las horas consolidadas con un solo clic. Como un hyperdrive para reportes.

¿Y para Otros Modelos?

El mismo patrón se aplica a otros modelos. Solo cambiá el modelo de destino y ajustá las rutas según corresponda.

#### Para Project.task

domain_tasks = [
    "|",
        ("partner_id.company_group_id", "=", group_id),
    "|",
        ("partner_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("project_id.partner_id.company_group_id", "=", group_id),
    "|",
        ("project_id.partner_id.parent_id.company_group_id", "=", group_id),
        ("company_group_id", "=", group_id),  # si el proyecto tiene company_group_id
]

Acá hay menos rutas porque las tareas no tienen relación directa con so_line ni invoice_partner_id. Pero agregamos project_id.partner_id.* y también la posibilidad de que el proyecto mismo tenga company_group_id (si hicieras esa extensión).

#### Para Sale.Order (Órdenes de Venta)

domain_sale = [
    "|",
        ("partner_id.company_group_id", "=", group_id),
    "|",
        ("partner_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("partner_invoice_id.company_group_id", "=", group_id),
    "|",
        ("partner_invoice_id.parent_id.company_group_id", "=", group_id),
    "|",
        ("partner_shipping_id.company_group_id", "=", group_id),
        ("partner_shipping_id.parent_id.company_group_id", "=", group_id),
]

Acá cubrimos: partner de la orden, partner de facturación y partner de envío. Cada uno puede ser una compañía del grupo o un contacto de una compañía del grupo.

Optimización: Cómo Reducir la Repetición

Si te molesta escribir 11 condiciones cada vez (y tenés razón, es verboso), una opción más elegante es crear un método helper que tome una lista de "caminos" y construya el dominio dinámicamente:

def _build_group_domain(self, field_paths, group_id):
    """
    Construye un dominio con OR entre todas las rutas.
    
    :param field_paths: lista de strings, ej:
        ["partner_id.company_group_id",
         "partner_id.parent_id.company_group_id",
         ...]
    :param group_id: ID del grupo cabecera
    :return: dominio Odoo list
    """
    if not field_paths:
        return []
    
    domain = []
    for path in field_paths[:-1]:
        domain.append("|")
        domain.append((path, "=", group_id))
    domain.append((field_paths[-1], "=", group_id))
    
    return domain

# Uso
paths = [
    "partner_id.company_group_id",
    "partner_id.parent_id.company_group_id",
    "task_id.partner_id.company_group_id",
    "task_id.partner_id.parent_id.company_group_id",
    "project_id.partner_id.company_group_id",
    "project_id.partner_id.parent_id.company_group_id",
    "so_line.order_id.partner_id.company_group_id",
    "so_line.order_id.partner_id.parent_id.company_group_id",
    "invoice_partner_id.company_group_id",
    "invoice_partner_id.parent_id.company_group_id",
]

domain = self._build_group_domain(paths, group_id)

Este helper genera exactamente el mismo dominio que escribimos manualmente, pero es más fácil de mantener. Si querés agregar una ruta nueva, solo la agregás a la lista. Si querés quitar una, la borrás. No necesitás recontar pipes.

¿Qué Pasa con los Campos Calculados?

Una pregunta frecuente: ¿y si creamos un campo company_group_id directamente en account.analytic.line que se compute automáticamente desde el partner?

Respuesta corta: sí, se puede. Respuesta larga: no lo hagas a menos que sea estrictamente necesario.

Razones:

  1. Performance: los stored computed fields se recalculan con cada escritura y con cada creación. Para un modelo como account.analytic.line que puede tener millones de registros, un stored computed field con dot-notation puede ser una bomba de relojería.
  1. Mantenimiento: si después cambiás la relación del partner (por ejemplo, migrás un contacto de una compañía a otra), el campo computed puede quedar desactualizado hasta el próximo recalculo.
  1. Complejidad innecesaria: el dominio con dot-notation ya resuelve el problema sin tocar el modelo de datos.

Excepción: si estás en PostgreSQL con tablas enormes y los JOINs de dot-notation son demasiado lentos, podés considerar un campo stored computed + índice. Pero medí primero. No optimices antes de tener datos.


El Ascenso de Skywalker — Resultados y Lecciones Aprendidas

Volvamos a nuestra consultora, la Alianza Rebelde, y veamos qué pasó después de implementar el company_group_id.

Antes vs. Después

EscenarioAntesDespués
Reporte mensual de horas del grupo Nova15 minutos navegando entre filtros, copiando a Excel, unificando5 segundos: un clic en el filtro guardado
Nuevo cliente: Grupo Solar (5 empresas)Configurar 5 filtros manualesAgregar las 5 empresas al grupo, un solo filtro nuevo
Error de facturación (horas asignadas a empresa equivocada)Detectable solo al cruzar reportesDetectable al instante porque el filtro del grupo muestra todo
Consulta del director: "¿cuántas horas le dedicamos al proyecto X en todas las empresas?"3 consultas separadas, unificación manualUn filtro, un resultado
Onboarding de nuevo consultorCapacitación de 30 min en "cómo filtrar por cada empresa""Elegí el grupo en el filtro, listo"

Lección 1: El Dominio es Poderoso, Pero Hay que Testearlo

El dominio de 11 condiciones que construimos parece infalible, pero en la práctica puede haber casos borde:

  • Contactos sin compañía madre: si tenés contactos que no tienen parent_id y tampoco tienen company_group_id, no van a aparecer en ningún filtro de grupo. Asegurate de que los datos estén completos.
  • Proyectos sin partner: si un proyecto no tiene partner_id, las líneas de timesheet de ese proyecto no van a ser capturadas por las rutas de project_id.partner_id.*. Pero... ¿un proyecto sin cliente? Eso ya es otro tema.
  • Múltiples grupos anidados: ¿un grupo dentro de otro grupo? El modelo no soporta anidación. company_group_id apunta a una única cabecera. Si necesitás jerarquías más complejas, considerá un modelo de res.company.group aparte.

Lección 2: La Disciplina de Datos es Clave

El company_group_id es tan útil como la calidad de los datos que le pongas. Si seteas el campo en algunas compañías del grupo pero te olvidás de otras, el filtro va a dar resultados incompletos y nadie va a confiar en él.

Recomendación: escribí un script de validación periódica que verifique que todas las compañías de un grupo tengan el company_group_id correcto:

def _validate_company_groups(self):
    """Verifica consistencia de company_group_id en compañías."""
    partners = self.env['res.partner'].search([
        ('is_company', '=', True),
        ('company_group_id', '!=', False),
    ])
    
    orphans = self.env['res.partner'].search([
        ('is_company', '=', True),
        ('company_group_id', '=', False),
        ('id', 'not in', partners.mapped('company_group_id.id')),
    ])
    
    if orphans:
        # Loggear o enviar notificación
        _logger.warning(
            "Compañías sin grupo: %s",
            orphans.mapped('name')
        )

Lección 3: Compartí el Conocimiento (Como los Jedi)

El filtro guardado no sirve de nada si solo lo conocés vos. Cuando crees un filtro de grupo:

  1. Guardalo como Compartido (no Privado).
  2. Ponele un nombre claro: "Timesheets - Grupo Nova".
  3. Agregá una descripción breve: "Todas las líneas de horas de EcoTech, Grupo Ambiental y NovaTech".
  4. Si usás la opción de botón inteligente, documentalo en la wiki interna.

El conocimiento compartido es poder. El conocimiento guardado en un solo cerebro se pierde cuando ese cerebro se toma vacaciones.

Lección 4: Escalabilidad

Este patrón escala bien hasta unos 20-30 grupos con 5-10 compañías cada uno. Más allá, considerá:

  • Índices de base de datos: si notás lentitud, agregá índices en company_group_id de res.partner y en los campos partner_id, project_id, task_id de account.analytic.line.
  • Vista materializada: si tenés reportes de miles de líneas con joins complejos, una vista materializada con el company_group_id precargado puede acelerar los reportes.
  • Caché de contexto: si el filtro se usa mucho, podés cachear el dominio en la sesión del usuario para no reconstruirlo cada vez.

Bonus: Integración con Reportes BI

Si usás Odoo BI (o cualquier herramienta externa tipo Power BI, Metabase, etc.), el company_group_id se vuelve aún más valioso. Podés agregarlo como dimensión en tus cubos de datos y permitir análisis multi-empresa sin configuraciones complejas.

Por ejemplo, en un reporte de account.analytic.line:

SELECT
    pg.name AS grupo_empresa,
    p.name AS empresa,
    al.date,
    al.unit_amount,
    al.employee_id,
    t.name AS tarea
FROM account_analytic_line al
LEFT JOIN res_partner p ON al.partner_id = p.id
LEFT JOIN res_partner pg ON p.company_group_id = pg.id
WHERE pg.id = 10  -- Corporación Nova
ORDER BY al.date DESC;

Con esta query, tenés todas las horas del grupo en un solo resultado plano, listo para pivotear en Power BI o Google Data Studio.


Bonus Track: La Expansión del Universo

Caso 1: Asignación Automática de Grupo por Dominio

Si tenés muchos partners y no querés setear company_group_id manualmente, podés usar reglas de automatización:

<record id="ir_cron_assign_company_group" model="ir.cron">
    <field name="name">Asignar grupo compañía por dominio de email</field>
    <field name="model_id" ref="base.model_res_partner"/>
    <field name="state">code</field>
    <field name="code">
        model._cron_assign_company_group()
    </field>
    <field name="interval_number">24</field>
    <field name="interval_type">hours</field>
    <field name="numbercall">-1</field>
    <field name="active">True</field>
</record>
def _cron_assign_company_group(self):
    # Asignar grupo automáticamente por dominio de email
    groups = {
        'corporacionnova.com': corp_nova_id,
        'grupo-solar.com': grupo_solar_id,
    }
    for domain, group_id in groups.items():
        partners = self.search([
            ('email', '=ilike', f'%@{domain}'),
            ('is_company', '=', True),
            ('company_group_id', '=', False),
        ])
        partners.write({'company_group_id': group_id})

Así, cuando se cree una nueva compañía con email @corporacionnova.com, se le asigna automáticamente el grupo "Corporación Nova". Sin intervención manual. Como la Fuerza, pero en código.

Caso 2: El Asistente de Filtro Rápido (Wizard)

Imaginá un asistente que te permita elegir un grupo compañía y te muestre todas las horas, tareas y ventas relacionadas, todo desde una pantalla unificada:

class GroupReportWizard(models.TransientModel):
    _name = 'group.report.wizard'
    _description = 'Reporte Consolidado por Grupo'

    company_group_id = fields.Many2one(
        'res.partner',
        string='Grupo Compañía',
        domain=[('is_company', '=', True)],
        required=True,
    )

    def action_show_timesheets(self):
        domain = self.env['res.partner']._build_group_domain(
            paths, self.company_group_id.id
        )
        return {
            'type': 'ir.actions.act_window',
            'name': f'Timesheets - {self.company_group_id.name}',
            'res_model': 'account.analytic.line',
            'view_mode': 'tree,graph,pivot',
            'domain': domain,
        }

Un asistente, un combo box, tres botones. Hasta un stormtrooper podría usarlo.

Caso 3: Portal del Cliente con Vista de Grupo

Si el cliente final (el director del grupo) tiene acceso al portal de Odoo, podés mostrarle un dashboard consolidado con todas las horas, tareas y facturas de TODAS sus empresas, usando el mismo company_group_id como filtro raíz.

En la vista del portal:

@http.route('/my/group/timesheets', type='http', auth='user')
def group_timesheets(self):
    user_partner = request.env.user.partner_id
    group_id = user_partner.company_group_id.id or user_partner.id
    
    domain = request.env['res.partner']._build_group_domain(paths, group_id)
    timesheets = request.env['account.analytic.line'].sudo().search(
        domain, limit=100
    )
    
    return request.render('module.group_timesheets_portal', {
        'timesheets': timesheets,
        'group_name': user_partner.company_group_id.name or user_partner.name,
    })

El director de Corporación Nova ingresa al portal, hace clic en "Mis Timesheets Consolidados", y ve las horas de EcoTech, Grupo Ambiental y NovaTech en una sola tabla. Sin filtros, sin configuraciones. Simple. Poderoso.


Epílogo: La Fuerza del Grupo Compañía

Llegamos al final de este viaje intergaláctico por los dominios de Odoo. Recapitulemos lo que aprendimos:

  1. El problema: las consultoras con clientes multi-empresa necesitan filtrar timesheets, tareas y ventas por grupo empresarial, no por compañía individual. Odoo no viene preparado para esto.
  1. La solución: un campo company_group_id en res.partner que agrupa compañías relacionadas bajo una cabecera común. Simple, flexible, extensible.
  1. La implementación: un dominio Odoo con dot-notation que navega por múltiples rutas (partner → parent → grupo, tarea → partner → grupo, proyecto → partner → grupo, etc.) conectadas con el operador '|' (OR).
  1. El resultado: un filtro único que captura todas las líneas de timesheet (y tareas, y ventas, y facturas) de un grupo empresarial completo, sin importar cómo estén vinculadas los datos.

El company_group_id no es magia. Es diseño inteligente de datos. Un campo, bien usado, puede transformar una docena de operaciones manuales en un solo clic. Y en el mundo de las consultoras, donde el tiempo es facturable y cada minuto cuenta, eso no es poca cosa.

Así que la próxima vez que un cliente te diga "somos un grupo de empresas", no entres en pánico. Sonreí, pensá en el company_group_id, y decile: "Tranqui, tenemos un plan para eso".

Y recordá siempre: Que la Fuerza del filtro te acompañe.


Apéndice A: Checklist de Implementación

  • [ ] Verificar que company_group_id existe en res.partner.
  • Si no existe: instalar módulo OCA partner_company_group o crear módulo custom.
  • [ ] Crear las compañías cabecera de grupo (tipo Compañía).
  • [ ] Asignar company_group_id a cada compañía subsidiaria.
  • [ ] Verificar que los contactos tengan parent_id correcto.
  • [ ] Construir el dominio con las 11 condiciones para account.analytic.line.
  • [ ] Probar el filtro con datos reales y verificar conteo de registros.
  • [ ] Guardar como filtro favorito (compartido).
  • [ ] (Opcional) Implementar botón inteligente en la ficha del partner.
  • [ ] (Opcional) Crear método helper _build_group_domain para reutilización.
  • [ ] Documentar para el equipo: cómo usar el filtro, cómo agregar empresas al grupo.
  • [ ] Configurar validación periódica de consistencia de datos.

Apéndice B: Otras Alternativas (Cuando no Tenés company_group_id)

Si por alguna razón no podés instalar ni crear el campo company_group_id (políticas de la empresa, restricciones del hosting, cliente que no quiere módulos custom), existen alternativas. Ninguna es tan elegante como la solución principal, pero pueden servir como puente.

Alternativa 1: Categorías de Partner (Tags)

Usá las categorías de partner (category_id) para etiquetar compañías de un mismo grupo:

  1. Creá una categoría "Grupo Nova" en Contactos > Configuración > Categorías de Partners.
  2. Asigná esa categoría a EcoTech, Grupo Ambiental y NovaTech.
  3. Filtrás por categoría.

Ventajas: no requiere módulo custom, fácil de configurar.

Desventajas: no hay relación de agrupación real (es solo una etiqueta), no podés hacer dot-notation desde partner_id.category_id porque es un campo Many2many y la dot-notation no funciona con esos.

Alternativa 2: Campo Text para Grupo

Creá un campo company_group_name (Char simple) en res.partner donde el nombre del grupo se escribe a mano:

company_group_name = fields.Char(string="Nombre del Grupo")

Ventajas: más simple que un Many2one, no necesita una compañía cabecera.

Desventajas: propenso a errores tipográficos ("Grupo Nova" vs "Grupo NOVA" vs "Corp Nova"), difícil de mantener consistente.

Alternativa 3: Campo Many2one a una Tabla Separada

En lugar de apuntar a res.partner, creá un modelo res.company.group específico:

class ResCompanyGroup(models.Model):
    _name = 'res.company.group'
    _description = 'Grupo de Compañías'

    name = fields.Char(required=True)
    company_ids = fields.One2many('res.partner', 'company_group_id')
    
    # Agregá campos útiles: responsable, 
    # condiciones de pago, descuentos por grupo, etc.
    account_manager_id = fields.Many2one('res.partner')
    payment_term_id = fields.Many2one('account.payment.term')

Ventajas: más limpio, separación de concerns, podés agregar metadatos del grupo.

Desventajas: más tablas, más relaciones, más complejidad.

Alternativa 4: Regla de Registro (Record Rule)

Si el objetivo es que los usuarios solo vean datos de su grupo, podés implementar una regla de registro multi-compañía usando grupos de usuarios y reglas de seguridad. Pero esto es para restringir, no para filtrar en reportes. Es un enfoque completamente diferente.

Comparativa Rápida

Enfoque¿Requiere módulo?Dot-notationMantenimientoEscalabilidad
company_group_id Many2one a res.partnerSí (custom)✅ TotalBajoAlta
Categorías (tags)No❌ Parcial (M2M)MedioMedia
company_group_name CharSí (custom)Alto (typos)Baja
Modelo separado res.company.groupSí (custom)✅ TotalBajoMuy alta
Record Rules + GruposNoN/AMedioAlta (c/limitaciones)

La moraleja: si podés, andá por company_group_id. Es el camino Jedi.


Apéndice C: Ejemplo de Módulo Custom Mínimo

Si necesitás crear el campo company_group_id desde cero:

__manifest__.py:

{
    'name': 'Partner Company Group',
    'version': '14.0.1.0.0',
    'depends': ['base', 'contacts'],
    'data': [
        'views/res_partner_views.xml',
    ],
}

models/res_partner.py:

from odoo import models, fields

class ResPartner(models.Model):
    _inherit = 'res.partner'

    company_group_id = fields.Many2one(
        'res.partner',
        string='Company Group',
        domain="[('is_company', '=', True)]",
        help="Parent company that groups this company under a corporate group."
    )

views/res_partner_views.xml:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="view_partner_form_inherit_group" model="ir.ui.view">
        <field name="name">res.partner.form.company.group</field>
        <field name="model">res.partner</field>
        <field name="inherit_id" ref="base.view_partner_form"/>
        <field name="arch" type="xml">
            <field name="parent_id" position="after">
                <field name="company_group_id"
                       attrs="{'invisible': [('is_company', '=', False)]}"/>
            </field>
        </field>
    </record>
</odoo>

Con esto, el campo aparece en el formulario de compañías (no en contactos), justo debajo de "Compañía madre". Sencillo, efectivo, listo para la acción.


Apéndice D: Performance — ¿Cómo Impactan los 11 OR en la Base de Datos?

Una pregunta que surge naturalmente: ¿no es una locura tener 11 condiciones OR en un dominio? ¿No va a explotar PostgreSQL?

La respuesta corta: depende del volumen de datos y de los índices. La respuesta larga: a continuación.

Cómo se Traduce en SQL

Cuando Odoo evalúa un dominio con dot-notation, hace JOINs. Nuestro dominio de 11 condiciones se traduce aproximadamente a:

SELECT al.*
FROM account_analytic_line al
LEFT JOIN res_partner p1 ON al.partner_id = p1.id
LEFT JOIN res_partner p1_parent ON p1.parent_id = p1_parent.id
LEFT JOIN project_task t ON al.task_id = t.id
LEFT JOIN res_partner p2 ON t.partner_id = p2.id
LEFT JOIN res_partner p2_parent ON p2.parent_id = p2_parent.id
LEFT JOIN project_project pr ON al.project_id = pr.id
LEFT JOIN res_partner p3 ON pr.partner_id = p3.id
LEFT JOIN res_partner p3_parent ON p3.parent_id = p3_parent.id
-- (y así sucesivamente con sale_order_line, account_move, etc.)
WHERE (
    p1.company_group_id = 10
    OR p1_parent.company_group_id = 10
    OR p2.company_group_id = 10
    OR p2_parent.company_group_id = 10
    OR p3.company_group_id = 10
    OR p3_parent.company_group_id = 10
    -- ...
)

Son muchos LEFT JOIN. Pero PostgreSQL es muy bueno optimizando esto. Los LEFT JOIN no son caros si la cardinalidad es manejable y si los índices están en su lugar.

Índices Recomendados

Para que esto vuele (como el Halcón Milenario en el Kessel Run), asegurate de tener estos índices:

-- Índice en company_group_id (el más importante)
CREATE INDEX idx_partner_company_group_id
ON res_partner (company_group_id)
WHERE company_group_id IS NOT NULL;

-- Índices en los campos FK que usa el JOIN
CREATE INDEX idx_analytic_line_partner_id
ON account_analytic_line (partner_id);

CREATE INDEX idx_analytic_line_task_id
ON account_analytic_line (task_id);

CREATE INDEX idx_analytic_line_project_id
ON account_analytic_line (project_id);

CREATE INDEX idx_task_partner_id
ON project_task (partner_id);

CREATE INDEX idx_project_partner_id
ON project_project (partner_id);

-- Índice en parent_id (para la navegación contacto → compañía)
CREATE INDEX idx_partner_parent_id
ON res_partner (parent_id)
WHERE parent_id IS NOT NULL;

Test de Performance

Hacé una prueba rápida antes de implementar en producción:

  1. Tomá una consultora con datos reales (digamos, 500.000 líneas de timesheet).
  2. Ejecutá el dominio con explain analyze en PostgreSQL.
  3. Verificá que los JOINs usen índices (deberías ver "Index Scan" o "Index Only Scan", no "Seq Scan").
  4. Si ves Sequential Scans en tablas grandes, necesitás los índices de arriba.

En mi experiencia, con índices adecuados y ~1 millón de líneas de timesheet, el dominio resuelve en menos de 200ms. Sin índices, puede tardar 5-10 segundos. La diferencia es abismal.

Caché de Consultas

Odoo tiene un ORM que cachea resultados de consultas repetitivas. Si el mismo usuario ejecuta el mismo filtro varias veces, la segunda ejecución va a ser más rápida porque el plan de ejecución ya está en caché. Para escenarios de reportes periódicos (fin de mes, cierre semanal), esto es una ventaja significativa.

¿Y Si Sigue Siendo Lento?

Si después de indexar todo sigue lento, considerá estas opciones:

  1. Vista materializada: creá una vista materializada con el company_group_id precargado en cada línea de timesheet. Actualizala periódicamente con un cron.
  1. Campo stored computed: agregá un campo company_group_id stored en account.analytic.line que se calcule desde el partner. Esto duplica datos pero acelera las consultas.
  1. Reporte separado: en lugar de filtrar en la vista estándar, creá un reporte wizard que ejecute la consulta optimizada y muestre los resultados en una vista separada.

Cada una tiene trade-offs. La vista materializada es la más recomendable para tablas muy grandes.


Este artículo fue escrito por un miembro de la Alianza Rebelde que pasó demasiadas horas construyendo dominios con notación polaca. Que la Fuerza del filtro te acompañe.

📬 ¿No querés perderte ningún artículo nuevo?

Dejanos tu correo y te avisamos cuando publiquemos algo.

¡Gracias por suscribirte!