erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-CONTABILIDAD-ANALITICA-MULTIDIMENSIONAL.md

35 KiB

Especificación Técnica: Contabilidad Analítica Multi-Dimensional

Código: SPEC-TRANS-005 Versión: 1.0 Fecha: 2025-12-08 Estado: Especificado Basado en: Odoo account.analytic.plan (v18.0)


1. Resumen Ejecutivo

1.1 Propósito

El Sistema de Contabilidad Analítica Multi-Dimensional permite distribuir costos e ingresos a través de múltiples dimensiones de análisis (proyectos, departamentos, centros de costo, categorías) simultáneamente, habilitando reportes de rentabilidad desde múltiples perspectivas.

1.2 Alcance

  • Planes Analíticos: Definición de dimensiones de análisis (Proyectos, Departamentos, etc.)
  • Cuentas Analíticas: Elementos específicos dentro de cada plan
  • Distribución Multi-dimensional: Asignar transacciones a múltiples cuentas de diferentes planes
  • Porcentajes flexibles: 60% Proyecto A + 40% Proyecto B AND 100% Departamento X
  • Modelos de Distribución: Templates automáticos basados en reglas
  • Aplicabilidad: Planes obligatorios vs opcionales por tipo de documento

1.3 Módulos Afectados

Módulo Rol
MGN-008 (Analítica) Core del sistema analítico
MGN-004 (Financiero) Integración con asientos contables
MGN-011 (Proyectos) Plan de proyectos
MGN-006 (Compras) Distribución en facturas de compra
MGN-007 (Ventas) Distribución en facturas de venta

1.4 Caso de Uso Principal - Construcción

Compra de cemento: $100,000
├── Distribución por Proyecto:
│   ├── Torre A: 60% ($60,000)
│   └── Torre B: 40% ($40,000)
├── Distribución por Departamento:
│   └── Construcción: 100% ($100,000)
└── Distribución por Categoría:
    └── Materiales: 100% ($100,000)

Resultado: 2 líneas analíticas
├── Línea 1: Torre A + Construcción + Materiales = $60,000
└── Línea 2: Torre B + Construcción + Materiales = $40,000

2. Modelo de Datos

2.1 Planes Analíticos

-- Definición de dimensiones de análisis
CREATE TABLE analytics.plans (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación
    name VARCHAR(255) NOT NULL,
    code VARCHAR(64),
    description TEXT,

    -- Jerarquía de planes
    parent_id UUID REFERENCES analytics.plans(id),
    parent_path VARCHAR(255),                    -- Materializado: /1/2/3/
    root_id UUID REFERENCES analytics.plans(id), -- Plan raíz

    -- Aplicabilidad por defecto
    default_applicability VARCHAR(20) NOT NULL DEFAULT 'optional'
        CHECK (default_applicability IN ('optional', 'mandatory', 'unavailable')),

    -- Presentación
    color INTEGER DEFAULT 0,
    sequence INTEGER NOT NULL DEFAULT 10,

    -- Multi-tenant
    company_id UUID REFERENCES core_auth.companies(id),
    tenant_id UUID NOT NULL,

    -- Estado
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_plan_code_tenant UNIQUE (code, tenant_id)
);

-- Índices
CREATE INDEX idx_plans_parent ON analytics.plans (parent_id);
CREATE INDEX idx_plans_root ON analytics.plans (root_id);
CREATE INDEX idx_plans_path ON analytics.plans USING GIST (parent_path gist_trgm_ops);

COMMENT ON TABLE analytics.plans IS 'Planes analíticos (dimensiones de análisis)';
COMMENT ON COLUMN analytics.plans.parent_path IS 'Path materializado para consultas jerárquicas';

2.2 Cuentas Analíticas

-- Cuentas dentro de cada plan
CREATE TABLE analytics.accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación
    name VARCHAR(255) NOT NULL,
    code VARCHAR(64),
    description TEXT,

    -- Plan al que pertenece
    plan_id UUID NOT NULL REFERENCES analytics.plans(id) ON DELETE RESTRICT,
    root_plan_id UUID REFERENCES analytics.plans(id), -- Calculado del plan

    -- Asociaciones
    partner_id UUID REFERENCES core_catalogs.partners(id),  -- Cliente/Proveedor asociado
    project_id UUID REFERENCES projects.projects(id),        -- Proyecto asociado

    -- Saldos (calculados)
    balance DECIMAL(16,4) NOT NULL DEFAULT 0,
    debit DECIMAL(16,4) NOT NULL DEFAULT 0,
    credit DECIMAL(16,4) NOT NULL DEFAULT 0,

    -- Multi-tenant
    company_id UUID REFERENCES core_auth.companies(id),
    tenant_id UUID NOT NULL,

    -- Estado
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_account_code_plan UNIQUE (code, plan_id, tenant_id)
);

-- Índices
CREATE INDEX idx_analytic_accounts_plan ON analytics.accounts (plan_id);
CREATE INDEX idx_analytic_accounts_root_plan ON analytics.accounts (root_plan_id);
CREATE INDEX idx_analytic_accounts_partner ON analytics.accounts (partner_id);
CREATE INDEX idx_analytic_accounts_project ON analytics.accounts (project_id);

COMMENT ON TABLE analytics.accounts IS 'Cuentas analíticas individuales dentro de planes';

2.3 Líneas Analíticas

-- Entradas analíticas (detalle de distribuciones)
CREATE TABLE analytics.lines (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Descripción
    name VARCHAR(500),
    date DATE NOT NULL,

    -- Montos
    amount DECIMAL(16,4) NOT NULL,
    unit_amount DECIMAL(16,4),                   -- Cantidad
    currency_id UUID NOT NULL REFERENCES core_catalogs.currencies(id),

    -- Referencias
    partner_id UUID REFERENCES core_catalogs.partners(id),
    product_id UUID REFERENCES inventory.products(id),
    user_id UUID REFERENCES core_auth.users(id),

    -- Conexión con contabilidad
    move_line_id UUID REFERENCES accounting.journal_entry_lines(id) ON DELETE CASCADE,
    general_account_id UUID REFERENCES accounting.accounts(id),

    -- CUENTAS ANALÍTICAS POR PLAN (dinámicas)
    -- El plan "Proyectos" usa account_id (columna principal)
    account_id UUID REFERENCES analytics.accounts(id),

    -- Otros planes usan columnas dinámicas: plan_{id}_account_id
    -- Estas se crean al agregar nuevos planes (ver sección 4)

    -- Multi-tenant
    company_id UUID NOT NULL REFERENCES core_auth.companies(id),
    tenant_id UUID NOT NULL,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES core_auth.users(id)
);

-- Índices principales
CREATE INDEX idx_analytic_lines_date ON analytics.lines (date);
CREATE INDEX idx_analytic_lines_account ON analytics.lines (account_id);
CREATE INDEX idx_analytic_lines_move ON analytics.lines (move_line_id);
CREATE INDEX idx_analytic_lines_account_date ON analytics.lines (account_id, date);

COMMENT ON TABLE analytics.lines IS 'Líneas analíticas - detalle de distribuciones';
COMMENT ON COLUMN analytics.lines.account_id IS 'Cuenta analítica del plan principal (Proyectos)';

2.4 Distribución Analítica (Campo JSON)

-- El campo analytic_distribution se agrega a múltiples tablas
-- Es un JSON con formato: { "account_id1,account_id2": percentage, ... }

-- Agregar a líneas de factura
ALTER TABLE accounting.journal_entry_lines
ADD COLUMN IF NOT EXISTS analytic_distribution JSONB;

-- Agregar a líneas de orden de compra
ALTER TABLE purchasing.purchase_order_lines
ADD COLUMN IF NOT EXISTS analytic_distribution JSONB;

-- Agregar a líneas de orden de venta
ALTER TABLE sales.sale_order_lines
ADD COLUMN IF NOT EXISTS analytic_distribution JSONB;

-- Índice GIN para búsquedas en distribución
CREATE INDEX idx_jel_analytic_dist ON accounting.journal_entry_lines
USING GIN (analytic_distribution);

COMMENT ON COLUMN accounting.journal_entry_lines.analytic_distribution IS
'Distribución analítica JSON: {"account_id1,account_id2": percentage, ...}';

2.5 Modelos de Distribución (Templates)

-- Templates para auto-poblar distribución analítica
CREATE TABLE analytics.distribution_models (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Distribución template
    analytic_distribution JSONB NOT NULL,

    -- Criterios de matching (todos opcionales, más específico = mayor prioridad)
    partner_id UUID REFERENCES core_catalogs.partners(id),
    partner_category_id UUID REFERENCES core_catalogs.partner_categories(id),
    product_category_id UUID REFERENCES inventory.product_categories(id),
    account_prefix VARCHAR(20),                  -- Prefijo de cuenta contable (ej: '61,62')

    -- Orden de aplicación
    sequence INTEGER NOT NULL DEFAULT 10,

    -- Multi-tenant
    company_id UUID REFERENCES core_auth.companies(id),
    tenant_id UUID NOT NULL,

    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_dist_models_partner ON analytics.distribution_models (partner_id);
CREATE INDEX idx_dist_models_sequence ON analytics.distribution_models (sequence);

COMMENT ON TABLE analytics.distribution_models IS 'Templates de distribución analítica automática';

2.6 Reglas de Aplicabilidad

-- Define cuándo un plan es obligatorio/opcional/no disponible
CREATE TABLE analytics.plan_applicability (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    plan_id UUID NOT NULL REFERENCES analytics.plans(id) ON DELETE CASCADE,

    -- Contexto de aplicación
    business_domain VARCHAR(50) NOT NULL
        CHECK (business_domain IN ('invoice', 'bill', 'general', 'expense', 'timesheet')),

    -- Criterios adicionales
    account_prefix VARCHAR(50),                  -- Prefijos de cuenta separados por coma
    product_category_id UUID REFERENCES inventory.product_categories(id),

    -- Nivel de requerimiento
    applicability VARCHAR(20) NOT NULL DEFAULT 'optional'
        CHECK (applicability IN ('optional', 'mandatory', 'unavailable')),

    -- Multi-tenant
    company_id UUID REFERENCES core_auth.companies(id),
    tenant_id UUID NOT NULL,

    sequence INTEGER NOT NULL DEFAULT 10,

    CONSTRAINT uq_applicability UNIQUE (plan_id, business_domain, company_id, tenant_id)
);

CREATE INDEX idx_applicability_plan ON analytics.plan_applicability (plan_id);
CREATE INDEX idx_applicability_domain ON analytics.plan_applicability (business_domain);

COMMENT ON TABLE analytics.plan_applicability IS 'Reglas de cuándo un plan es obligatorio';

3. Formato de Distribución JSON

3.1 Estructura

/**
 * Formato del campo analytic_distribution
 *
 * Keys: IDs de cuentas analíticas separados por coma (de diferentes planes)
 * Values: Porcentaje asignado (0-100)
 */
type AnalyticDistribution = {
  [accountIds: string]: number;  // "uuid1,uuid2": 60
};

// Ejemplos:

// Distribución simple (un solo plan)
const simple: AnalyticDistribution = {
  "project-a-uuid": 60,
  "project-b-uuid": 40
};

// Distribución multi-dimensional
const multiDim: AnalyticDistribution = {
  "project-a-uuid,dept-construction-uuid": 60,
  "project-b-uuid,dept-construction-uuid": 40
};

// Distribución compleja (3 dimensiones)
const complex: AnalyticDistribution = {
  "proj-a,dept-x,cat-materials": 30,
  "proj-a,dept-x,cat-labor": 30,
  "proj-b,dept-x,cat-materials": 20,
  "proj-b,dept-x,cat-labor": 20
};

3.2 Reglas de Validación

interface DistributionValidation {
  // Por cada plan raíz, la suma debe ser 100% (si es mandatory)
  // O puede ser < 100% (si es optional)
  planTotals: Map<UUID, number>;  // planId -> totalPercentage

  // Ejemplo válido:
  // Plan Proyectos: 60% + 40% = 100% ✓
  // Plan Departamentos: 100% ✓
  // Plan Categorías: 100% ✓

  // Ejemplo inválido (plan mandatory):
  // Plan Proyectos: 60% + 30% = 90% ✗ (debe ser 100%)
}

function validateDistribution(
  distribution: AnalyticDistribution,
  mandatoryPlans: UUID[]
): ValidationResult {

  const planTotals = new Map<UUID, number>();

  for (const [accountIds, percentage] of Object.entries(distribution)) {
    const accounts = await getAccounts(accountIds.split(','));

    for (const account of accounts) {
      const rootPlanId = account.root_plan_id;
      const current = planTotals.get(rootPlanId) || 0;
      planTotals.set(rootPlanId, current + percentage);
    }
  }

  // Validar planes obligatorios
  for (const planId of mandatoryPlans) {
    const total = planTotals.get(planId) || 0;
    if (Math.abs(total - 100) > 0.01) {
      return {
        valid: false,
        error: `Plan ${planId} requiere distribución del 100%, tiene ${total}%`
      };
    }
  }

  return { valid: true };
}

4. Columnas Dinámicas por Plan

4.1 Estrategia de Columnas

Cuando se crea un nuevo plan analítico, se agrega una columna FK a la tabla analytics.lines:

async function onPlanCreated(plan: AnalyticPlan): Promise<void> {
  // El primer plan (Proyectos) usa la columna account_id existente
  if (isFirstPlan(plan)) {
    return;
  }

  // Otros planes crean columna dinámica
  const columnName = `plan_${plan.id}_account_id`;

  await db.query(`
    ALTER TABLE analytics.lines
    ADD COLUMN IF NOT EXISTS ${columnName} UUID
    REFERENCES analytics.accounts(id);

    CREATE INDEX IF NOT EXISTS idx_analytic_lines_${plan.id}
    ON analytics.lines (${columnName})
    WHERE ${columnName} IS NOT NULL;
  `);
}

4.2 Mapeo de Columnas

interface PlanColumnMapping {
  planId: UUID;
  columnName: string;
  isMainColumn: boolean;
}

function getPlanColumnName(plan: AnalyticPlan): string {
  // El plan "Proyectos" (configurado en settings) usa account_id
  const projectPlanId = await getSetting('analytic.project_plan_id');

  if (plan.root_id === projectPlanId || plan.id === projectPlanId) {
    return 'account_id';
  }

  // Otros planes usan columna dinámica
  return `plan_${plan.root_id}_account_id`;
}

5. Servicio de Distribución Analítica

5.1 Crear Líneas Analíticas

interface CreateAnalyticLinesParams {
  moveLineId: UUID;
  distribution: AnalyticDistribution;
  amount: number;
  date: Date;
  companyId: UUID;
  partnerId?: UUID;
  productId?: UUID;
}

class AnalyticDistributionService {

  /**
   * Crea líneas analíticas a partir de una distribución
   */
  async createAnalyticLines(params: CreateAnalyticLinesParams): Promise<AnalyticLine[]> {
    const { distribution, amount, date, companyId, moveLineId } = params;

    // 1. Validar distribución
    const mandatoryPlans = await this.getMandatoryPlans(params);
    const validation = await this.validateDistribution(distribution, mandatoryPlans);

    if (!validation.valid) {
      throw new ValidationError(validation.error);
    }

    // 2. Preparar líneas analíticas
    const lineValues: AnalyticLineInput[] = [];
    const distributionByPlan = new Map<UUID, number>();

    for (const [accountIdsStr, percentage] of Object.entries(distribution)) {
      const accountIds = accountIdsStr.split(',');
      const accounts = await this.getAccounts(accountIds);

      // Calcular monto para esta combinación
      const lineAmount = this.calculateAmount(amount, percentage, distributionByPlan, accounts);

      // Preparar valores de línea
      const lineValue: AnalyticLineInput = {
        name: await this.getDescription(moveLineId),
        date,
        amount: lineAmount,
        move_line_id: moveLineId,
        company_id: companyId,
        partner_id: params.partnerId,
        product_id: params.productId
      };

      // Asignar cuenta a columna correcta según plan
      for (const account of accounts) {
        const columnName = getPlanColumnName(account.plan);
        lineValue[columnName] = account.id;
      }

      lineValues.push(lineValue);
    }

    // 3. Distribuir errores de redondeo
    this.distributeRoundingError(lineValues, amount);

    // 4. Crear líneas en batch
    return this.createLines(lineValues);
  }

  /**
   * Calcula el monto para una línea considerando progreso por plan
   */
  private calculateAmount(
    totalAmount: number,
    percentage: number,
    distributionByPlan: Map<UUID, number>,
    accounts: AnalyticAccount[]
  ): number {

    // Obtener plan raíz de las cuentas
    const rootPlan = accounts[0].root_plan_id;

    // Acumular porcentaje por plan
    const previousTotal = distributionByPlan.get(rootPlan) || 0;
    const newTotal = previousTotal + percentage;
    distributionByPlan.set(rootPlan, newTotal);

    // Si llegamos a 100%, calcular como diferencia (evita errores de redondeo)
    if (Math.abs(newTotal - 100) < 0.01) {
      const previousAmount = totalAmount * previousTotal / 100;
      return -(totalAmount - previousAmount);
    }

    return -(totalAmount * percentage / 100);
  }

  /**
   * Distribuye errores de redondeo entre líneas
   */
  private distributeRoundingError(lines: AnalyticLineInput[], expectedTotal: number): void {
    const actualTotal = lines.reduce((sum, l) => sum + l.amount, 0);
    const error = expectedTotal + actualTotal;  // amount es negativo

    if (Math.abs(error) > 0.001 && lines.length > 0) {
      // Distribuir error en la última línea
      lines[lines.length - 1].amount -= error;
    }
  }
}

5.2 Obtener Distribución desde Modelos

interface DistributionContext {
  partnerId?: UUID;
  partnerCategoryId?: UUID;
  productCategoryId?: UUID;
  accountCode?: string;
  companyId: UUID;
}

class DistributionModelService {

  /**
   * Obtiene distribución automática basada en modelos configurados
   */
  async getDistribution(context: DistributionContext): Promise<AnalyticDistribution | null> {
    // 1. Buscar modelos que coincidan con el contexto
    const models = await this.findMatchingModels(context);

    if (models.length === 0) {
      return null;
    }

    // 2. Mergear distribuciones (por secuencia, evitando duplicados por plan)
    const mergedDistribution: AnalyticDistribution = {};
    const appliedPlans = new Set<UUID>();

    for (const model of models) {
      for (const [accountIds, percentage] of Object.entries(model.analytic_distribution)) {
        const accounts = await this.getAccounts(accountIds.split(','));

        // Solo aplicar si el plan no fue aplicado antes
        const planIds = accounts.map(a => a.root_plan_id);
        const allNew = planIds.every(pid => !appliedPlans.has(pid));

        if (allNew) {
          mergedDistribution[accountIds] = percentage;
          planIds.forEach(pid => appliedPlans.add(pid));
        }
      }
    }

    return Object.keys(mergedDistribution).length > 0 ? mergedDistribution : null;
  }

  /**
   * Busca modelos ordenados por relevancia (más específico primero)
   */
  private async findMatchingModels(context: DistributionContext): Promise<DistributionModel[]> {
    // Construir query con scoring
    const models = await db.query(`
      SELECT dm.*,
        (CASE WHEN dm.partner_id = $1 THEN 4 ELSE 0 END) +
        (CASE WHEN dm.partner_category_id = $2 THEN 2 ELSE 0 END) +
        (CASE WHEN dm.product_category_id = $3 THEN 2 ELSE 0 END) +
        (CASE WHEN $4 LIKE dm.account_prefix || '%' THEN 1 ELSE 0 END) +
        (CASE WHEN dm.company_id = $5 THEN 0.5 ELSE 0 END) AS score
      FROM analytics.distribution_models dm
      WHERE dm.is_active = TRUE
        AND dm.tenant_id = $6
        AND (dm.company_id IS NULL OR dm.company_id = $5)
        AND (
          dm.partner_id = $1 OR dm.partner_id IS NULL
        )
        AND (
          dm.partner_category_id = $2 OR dm.partner_category_id IS NULL
        )
        AND (
          dm.product_category_id = $3 OR dm.product_category_id IS NULL
        )
        AND (
          dm.account_prefix IS NULL
          OR $4 LIKE dm.account_prefix || '%'
        )
      HAVING score > 0
      ORDER BY score DESC, dm.sequence ASC
    `, [
      context.partnerId,
      context.partnerCategoryId,
      context.productCategoryId,
      context.accountCode,
      context.companyId,
      context.tenantId
    ]);

    return models;
  }
}

5.3 Validar Planes Obligatorios

class PlanApplicabilityService {

  /**
   * Obtiene planes relevantes para un contexto
   */
  async getRelevantPlans(context: {
    businessDomain: 'invoice' | 'bill' | 'general' | 'expense' | 'timesheet';
    accountCode?: string;
    productCategoryId?: UUID;
    companyId: UUID;
  }): Promise<PlanApplicability[]> {

    const plans = await db.query(`
      SELECT
        p.id AS plan_id,
        p.name AS plan_name,
        COALESCE(pa.applicability, p.default_applicability) AS applicability,
        (
          CASE WHEN pa.company_id IS NOT NULL THEN 0.5 ELSE 0 END +
          CASE WHEN pa.account_prefix IS NOT NULL AND $2 LIKE pa.account_prefix || '%' THEN 1 ELSE 0 END +
          CASE WHEN pa.product_category_id = $3 THEN 1 ELSE 0 END
        ) AS score
      FROM analytics.plans p
      LEFT JOIN analytics.plan_applicability pa ON pa.plan_id = p.id
        AND pa.business_domain = $1
        AND (pa.company_id IS NULL OR pa.company_id = $4)
      WHERE p.is_active = TRUE
        AND p.tenant_id = $5
        AND p.parent_id IS NULL  -- Solo planes raíz
      ORDER BY p.sequence, score DESC
    `, [
      context.businessDomain,
      context.accountCode,
      context.productCategoryId,
      context.companyId,
      context.tenantId
    ]);

    // Filtrar por mejor score para cada plan
    const bestByPlan = new Map<UUID, PlanApplicability>();
    for (const plan of plans) {
      if (!bestByPlan.has(plan.plan_id) || plan.score > bestByPlan.get(plan.plan_id)!.score) {
        bestByPlan.set(plan.plan_id, plan);
      }
    }

    return Array.from(bestByPlan.values())
      .filter(p => p.applicability !== 'unavailable');
  }

  /**
   * Obtiene IDs de planes obligatorios
   */
  async getMandatoryPlanIds(context: ApplicabilityContext): Promise<UUID[]> {
    const plans = await this.getRelevantPlans(context);
    return plans
      .filter(p => p.applicability === 'mandatory')
      .map(p => p.plan_id);
  }
}

6. Integración con Asientos Contables

6.1 Hook en Publicación de Factura

class JournalEntryService {

  /**
   * Crea líneas analíticas al publicar asiento
   */
  async postEntry(entryId: UUID): Promise<void> {
    const entry = await this.getEntry(entryId);

    // Validar distribuciones obligatorias
    await this.validateAnalyticDistributions(entry);

    // Crear líneas analíticas
    for (const line of entry.lines) {
      if (line.analytic_distribution && Object.keys(line.analytic_distribution).length > 0) {
        await this.analyticService.createAnalyticLines({
          moveLineId: line.id,
          distribution: line.analytic_distribution,
          amount: line.balance,
          date: entry.date,
          companyId: entry.company_id,
          partnerId: line.partner_id,
          productId: line.product_id
        });
      }
    }

    // Actualizar estado
    await this.updateState(entryId, 'posted');
  }

  /**
   * Valida que planes obligatorios tengan distribución 100%
   */
  private async validateAnalyticDistributions(entry: JournalEntry): Promise<void> {
    for (const line of entry.lines) {
      if (!line.analytic_distribution) continue;

      const mandatoryPlans = await this.applicabilityService.getMandatoryPlanIds({
        businessDomain: this.getBusinessDomain(entry),
        accountCode: line.account_code,
        productCategoryId: line.product?.category_id,
        companyId: entry.company_id
      });

      const validation = await this.distributionService.validateDistribution(
        line.analytic_distribution,
        mandatoryPlans
      );

      if (!validation.valid) {
        throw new ValidationError(
          `Línea "${line.name}": ${validation.error}`
        );
      }
    }
  }
}

6.2 Sincronización Bidireccional

class AnalyticSyncService {

  /**
   * Actualiza distribución JSON desde líneas analíticas
   * (cuando se editan líneas analíticas directamente)
   */
  async syncDistributionFromLines(moveLineId: UUID): Promise<void> {
    const moveLine = await this.getMoveLine(moveLineId);
    const analyticLines = await this.getAnalyticLines(moveLineId);

    if (analyticLines.length === 0) {
      await this.updateDistribution(moveLineId, null);
      return;
    }

    // Reconstruir distribución JSON
    const distribution: AnalyticDistribution = {};

    for (const line of analyticLines) {
      // Obtener key combinando cuentas de todos los planes
      const accountIds = this.extractAccountIds(line);
      const key = accountIds.join(',');

      // Calcular porcentaje
      const percentage = moveLine.balance !== 0
        ? Math.abs(line.amount / moveLine.balance) * 100
        : 100;

      distribution[key] = Math.round(percentage * 100) / 100;  // 2 decimales
    }

    await this.updateDistribution(moveLineId, distribution);
  }

  /**
   * Extrae IDs de cuentas de una línea analítica
   */
  private extractAccountIds(line: AnalyticLine): UUID[] {
    const ids: UUID[] = [];

    // Columna principal (Proyectos)
    if (line.account_id) {
      ids.push(line.account_id);
    }

    // Columnas dinámicas de otros planes
    for (const [column, value] of Object.entries(line)) {
      if (column.startsWith('plan_') && column.endsWith('_account_id') && value) {
        ids.push(value as UUID);
      }
    }

    return ids.sort();  // Orden consistente
  }
}

7. API REST

7.1 Endpoints de Planes

GET    /api/v1/analytics/plans                 # Listar planes
POST   /api/v1/analytics/plans                 # Crear plan
GET    /api/v1/analytics/plans/:id             # Obtener plan
PUT    /api/v1/analytics/plans/:id             # Actualizar plan
DELETE /api/v1/analytics/plans/:id             # Desactivar plan

GET    /api/v1/analytics/plans/:id/accounts    # Cuentas del plan
GET    /api/v1/analytics/plans/relevant        # Planes relevantes para contexto

GET /api/v1/analytics/plans/relevant

Query Params:

Param Tipo Descripción
businessDomain string invoice, bill, general, expense
accountCode string Código de cuenta contable
productCategoryId uuid Categoría de producto

Response:

{
  "plans": [
    {
      "id": "uuid",
      "name": "Proyectos",
      "applicability": "mandatory",
      "accounts": [
        { "id": "uuid", "name": "Torre A", "code": "PROJ-001" },
        { "id": "uuid", "name": "Torre B", "code": "PROJ-002" }
      ]
    },
    {
      "id": "uuid",
      "name": "Departamentos",
      "applicability": "optional",
      "accounts": [
        { "id": "uuid", "name": "Construcción", "code": "DEPT-CONST" },
        { "id": "uuid", "name": "Administración", "code": "DEPT-ADMIN" }
      ]
    }
  ]
}

7.2 Endpoints de Cuentas Analíticas

GET    /api/v1/analytics/accounts              # Listar cuentas
POST   /api/v1/analytics/accounts              # Crear cuenta
GET    /api/v1/analytics/accounts/:id          # Obtener cuenta con saldos
PUT    /api/v1/analytics/accounts/:id          # Actualizar cuenta
DELETE /api/v1/analytics/accounts/:id          # Desactivar cuenta

GET    /api/v1/analytics/accounts/:id/lines    # Líneas de la cuenta
GET    /api/v1/analytics/accounts/:id/balance  # Saldo con filtros

GET /api/v1/analytics/accounts/:id/balance

Query Params:

Param Tipo Descripción
dateFrom date Fecha inicio
dateTo date Fecha fin
groupBy string plan, month, partner

Response:

{
  "accountId": "uuid",
  "accountName": "Torre A",
  "dateRange": { "from": "2025-01-01", "to": "2025-12-31" },
  "totals": {
    "debit": 1500000.00,
    "credit": 500000.00,
    "balance": 1000000.00
  },
  "breakdown": [
    {
      "groupKey": "2025-01",
      "debit": 200000.00,
      "credit": 50000.00,
      "balance": 150000.00
    }
  ]
}

7.3 Endpoints de Distribución

POST   /api/v1/analytics/distribution/validate # Validar distribución
POST   /api/v1/analytics/distribution/suggest  # Sugerir distribución
GET    /api/v1/analytics/distribution/models   # Listar modelos
POST   /api/v1/analytics/distribution/models   # Crear modelo

POST /api/v1/analytics/distribution/validate

Request:

{
  "distribution": {
    "proj-a-uuid,dept-x-uuid": 60,
    "proj-b-uuid,dept-x-uuid": 40
  },
  "context": {
    "businessDomain": "bill",
    "accountCode": "6101",
    "companyId": "uuid"
  }
}

Response:

{
  "valid": true,
  "planSummary": [
    { "planId": "uuid", "planName": "Proyectos", "total": 100, "required": 100, "status": "ok" },
    { "planId": "uuid", "planName": "Departamentos", "total": 100, "required": null, "status": "ok" }
  ]
}

8. Reportes Analíticos

8.1 Reporte P&L por Cuenta Analítica

interface AnalyticPLReportParams {
  accountIds: UUID[];           // Cuentas analíticas a incluir
  planId?: UUID;                // Filtrar por plan
  dateFrom: Date;
  dateTo: Date;
  companyId: UUID;
  comparison?: 'previous_period' | 'previous_year';
}

async function generateAnalyticPLReport(params: AnalyticPLReportParams): Promise<AnalyticPLReport> {
  const query = `
    WITH analytic_totals AS (
      SELECT
        al.account_id,
        aa.name AS account_name,
        ap.name AS plan_name,
        ga.account_type,
        SUM(CASE WHEN ga.account_type LIKE 'income%' THEN -al.amount ELSE 0 END) AS income,
        SUM(CASE WHEN ga.account_type LIKE 'expense%' THEN al.amount ELSE 0 END) AS expense
      FROM analytics.lines al
      JOIN analytics.accounts aa ON al.account_id = aa.id
      JOIN analytics.plans ap ON aa.plan_id = ap.id
      JOIN accounting.accounts ga ON al.general_account_id = ga.id
      WHERE al.date >= $1
        AND al.date <= $2
        AND al.company_id = $3
        AND ($4::uuid[] IS NULL OR al.account_id = ANY($4))
        AND ($5::uuid IS NULL OR ap.root_id = $5 OR ap.id = $5)
      GROUP BY al.account_id, aa.name, ap.name, ga.account_type
    )
    SELECT
      account_id,
      account_name,
      plan_name,
      SUM(income) AS total_income,
      SUM(expense) AS total_expense,
      SUM(income) - SUM(expense) AS profit
    FROM analytic_totals
    GROUP BY account_id, account_name, plan_name
    ORDER BY plan_name, account_name
  `;

  return db.query(query, [
    params.dateFrom,
    params.dateTo,
    params.companyId,
    params.accountIds.length > 0 ? params.accountIds : null,
    params.planId
  ]);
}

8.2 Reporte Multi-dimensional

interface MultiDimensionalReportParams {
  dimensions: UUID[];           // IDs de planes a incluir como dimensiones
  dateFrom: Date;
  dateTo: Date;
  companyId: UUID;
  metric: 'amount' | 'count';
}

async function generateMultiDimensionalReport(
  params: MultiDimensionalReportParams
): Promise<MultiDimensionalReport> {

  // Construir columnas dinámicas basadas en planes
  const plans = await getPlans(params.dimensions);
  const columnSelects = plans.map(p => {
    const col = getPlanColumnName(p);
    return `al.${col} AS ${p.code}_account_id, aa_${p.code}.name AS ${p.code}_name`;
  });

  const columnJoins = plans.map(p => {
    const col = getPlanColumnName(p);
    return `LEFT JOIN analytics.accounts aa_${p.code} ON al.${col} = aa_${p.code}.id`;
  });

  const groupByCols = plans.map(p => `al.${getPlanColumnName(p)}, aa_${p.code}.name`);

  const query = `
    SELECT
      ${columnSelects.join(',\n      ')},
      SUM(al.amount) AS total_amount,
      COUNT(*) AS line_count
    FROM analytics.lines al
    ${columnJoins.join('\n    ')}
    WHERE al.date >= $1
      AND al.date <= $2
      AND al.company_id = $3
    GROUP BY ${groupByCols.join(', ')}
    ORDER BY ${groupByCols.join(', ')}
  `;

  return db.query(query, [params.dateFrom, params.dateTo, params.companyId]);
}

9. Configuración Inicial

9.1 Planes Estándar para Construcción

-- Crear planes base
INSERT INTO analytics.plans (id, name, code, default_applicability, sequence, tenant_id) VALUES
('plan-projects', 'Proyectos', 'PROJ', 'mandatory', 1, 'tenant-id'),
('plan-departments', 'Departamentos', 'DEPT', 'optional', 2, 'tenant-id'),
('plan-cost-categories', 'Categorías de Costo', 'CAT', 'optional', 3, 'tenant-id');

-- Crear cuentas ejemplo
INSERT INTO analytics.accounts (id, name, code, plan_id, tenant_id) VALUES
-- Proyectos
('proj-torre-a', 'Torre A - Residencial', 'PROJ-001', 'plan-projects', 'tenant-id'),
('proj-torre-b', 'Torre B - Comercial', 'PROJ-002', 'plan-projects', 'tenant-id'),
-- Departamentos
('dept-construction', 'Construcción', 'DEPT-CONST', 'plan-departments', 'tenant-id'),
('dept-admin', 'Administración', 'DEPT-ADMIN', 'plan-departments', 'tenant-id'),
-- Categorías
('cat-materials', 'Materiales', 'CAT-MAT', 'plan-cost-categories', 'tenant-id'),
('cat-labor', 'Mano de Obra', 'CAT-LAB', 'plan-cost-categories', 'tenant-id'),
('cat-equipment', 'Equipo', 'CAT-EQU', 'plan-cost-categories', 'tenant-id');

-- Configurar aplicabilidad
INSERT INTO analytics.plan_applicability (plan_id, business_domain, applicability, account_prefix, tenant_id) VALUES
-- Proyectos obligatorio para gastos (cuentas 5xxx, 6xxx)
('plan-projects', 'bill', 'mandatory', '5,6', 'tenant-id'),
('plan-projects', 'expense', 'mandatory', NULL, 'tenant-id'),
-- Departamentos opcional
('plan-departments', 'bill', 'optional', NULL, 'tenant-id');

9.2 Modelo de Distribución Automática

-- Auto-distribuir compras de materiales al departamento de construcción
INSERT INTO analytics.distribution_models (
  analytic_distribution,
  product_category_id,
  account_prefix,
  sequence,
  tenant_id
) VALUES (
  '{"dept-construction": 100}',
  'cat-materiales-construccion',
  '5',
  10,
  'tenant-id'
);

-- Auto-distribuir gastos administrativos
INSERT INTO analytics.distribution_models (
  analytic_distribution,
  account_prefix,
  sequence,
  tenant_id
) VALUES (
  '{"dept-admin": 100}',
  '61',
  20,
  'tenant-id'
);

10. Consideraciones de Performance

10.1 Índices Recomendados

-- Índice compuesto para reportes por cuenta y fecha
CREATE INDEX idx_analytic_lines_account_date_amount
ON analytics.lines (account_id, date)
INCLUDE (amount);

-- Índice GIN para búsqueda en distribución JSON
CREATE INDEX idx_distribution_accounts
ON accounting.journal_entry_lines
USING GIN ((
  SELECT array_agg(elem::uuid)
  FROM jsonb_object_keys(analytic_distribution) AS elem
));

-- Índices parciales para columnas de planes opcionales
CREATE INDEX idx_lines_plan2_not_null
ON analytics.lines (plan_2_account_id)
WHERE plan_2_account_id IS NOT NULL;

10.2 Materialización de Saldos

-- Vista materializada para saldos por cuenta
CREATE MATERIALIZED VIEW analytics.account_balances AS
SELECT
  account_id,
  date_trunc('month', date) AS month,
  SUM(amount) AS balance,
  SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS debit,
  SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END) AS credit,
  COUNT(*) AS line_count
FROM analytics.lines
GROUP BY account_id, date_trunc('month', date);

CREATE UNIQUE INDEX idx_account_balances_pk
ON analytics.account_balances (account_id, month);

-- Refresh periódico
REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.account_balances;

11. Testing

11.1 Casos de Prueba

  1. Distribución simple: 100% a una cuenta
  2. Distribución dividida: 60/40 entre dos cuentas del mismo plan
  3. Multi-dimensional: 60/40 proyectos + 100% departamento
  4. Validación: Plan mandatory sin 100% debe fallar
  5. Modelos automáticos: Distribución se aplica correctamente
  6. Redondeo: Montos suman exactamente el total
  7. Sincronización: Editar líneas actualiza distribución JSON

12. Referencias

  • Odoo Source: addons/analytic/models/analytic_plan.py
  • Odoo Source: addons/analytic/models/analytic_mixin.py
  • NIF C-4: Inventarios (para costeo por proyecto)
  • Construcción: Catálogo de conceptos INFONAVIT

Historial de Cambios

Versión Fecha Autor Cambios
1.0 2025-12-08 Requirements-Analyst Versión inicial