erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-REPORTES-FINANCIEROS.md

46 KiB

Especificación Técnica: Reportes Financieros Estándar

Código: SPEC-TRANS-004 Versión: 1.0 Fecha: 2025-12-08 Estado: Especificado Basado en: Odoo account.report (v18.0), NIF México, SAT


1. Resumen Ejecutivo

1.1 Propósito

El Sistema de Reportes Financieros genera los estados financieros estándar requeridos para cumplimiento legal y toma de decisiones:

  • Balance General (Estado de Situación Financiera): Posición financiera a una fecha
  • Estado de Resultados (P&L): Rendimiento en un período
  • Comparativos multi-período: Análisis de variaciones

1.2 Alcance

  • Definición flexible de líneas de reporte mediante expresiones
  • Mapeo automático de cuentas contables a secciones
  • Comparativos: período actual vs anterior, año vs año anterior
  • Filtros: fechas, diarios, cuentas analíticas, socios
  • Exportación: PDF, Excel, CSV, JSON API
  • Soporte multi-empresa y multi-moneda
  • Localización México (SAT/NIF)

1.3 Módulos Afectados

Módulo Rol
MGN-004 (Financiero) Generación de reportes, configuración
MGN-012 (Reportes) Motor de renderizado, exportación
MGN-003 (Catálogos) Plan de cuentas, grupos

2. Modelo de Datos

2.1 Definición de Reportes

-- Tabla de definición de reportes financieros
CREATE TABLE accounting.financial_reports (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Identificación
    code VARCHAR(64) NOT NULL,                  -- Código único (balance_sheet, profit_loss)
    name VARCHAR(255) NOT NULL,                 -- Nombre del reporte
    description TEXT,

    -- Configuración base
    report_type VARCHAR(50) NOT NULL            -- balance_sheet, profit_loss, cash_flow, custom
        CHECK (report_type IN ('balance_sheet', 'profit_loss', 'cash_flow', 'trial_balance', 'custom')),

    -- Localización
    country_code VARCHAR(3),                    -- MX, US, CO (NULL = genérico)
    chart_template_id UUID REFERENCES accounting.chart_templates(id),

    -- Configuración de columnas
    columns_config JSONB NOT NULL DEFAULT '[]', -- Definición de columnas

    -- Filtros habilitados
    filter_date_range BOOLEAN NOT NULL DEFAULT TRUE,
    filter_comparison BOOLEAN NOT NULL DEFAULT TRUE,
    filter_journals BOOLEAN NOT NULL DEFAULT FALSE,
    filter_analytic BOOLEAN NOT NULL DEFAULT FALSE,
    filter_partners BOOLEAN NOT NULL DEFAULT FALSE,
    filter_hierarchy BOOLEAN NOT NULL DEFAULT TRUE,  -- Mostrar grupos de cuentas
    filter_hide_zero BOOLEAN NOT NULL DEFAULT TRUE,
    filter_draft BOOLEAN NOT NULL DEFAULT FALSE,     -- Incluir asientos borrador

    -- Opciones de visualización
    show_growth_comparison BOOLEAN NOT NULL DEFAULT FALSE,
    decimal_places INTEGER NOT NULL DEFAULT 2,

    -- Reporte padre (para variantes)
    parent_report_id UUID REFERENCES accounting.financial_reports(id),

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

    -- Estado
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    sequence INTEGER NOT NULL DEFAULT 10,

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

    CONSTRAINT uq_report_code_tenant UNIQUE (code, tenant_id)
);

CREATE INDEX idx_reports_type ON accounting.financial_reports (report_type);
CREATE INDEX idx_reports_country ON accounting.financial_reports (country_code);

COMMENT ON TABLE accounting.financial_reports IS 'Definición de reportes financieros configurables';

2.2 Líneas de Reporte

-- Líneas que componen cada reporte
CREATE TABLE accounting.financial_report_lines (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    report_id UUID NOT NULL REFERENCES accounting.financial_reports(id) ON DELETE CASCADE,

    -- Identificación
    code VARCHAR(64) NOT NULL,                  -- Código para referencias (TOTAL_ASSETS)
    name VARCHAR(255) NOT NULL,                 -- Nombre a mostrar

    -- Jerarquía
    parent_id UUID REFERENCES accounting.financial_report_lines(id),
    sequence INTEGER NOT NULL DEFAULT 10,
    hierarchy_level INTEGER GENERATED ALWAYS AS (
        CASE WHEN parent_id IS NULL THEN 0
        ELSE (SELECT hierarchy_level + 1 FROM accounting.financial_report_lines p WHERE p.id = parent_id)
        END
    ) STORED,

    -- Tipo de línea
    line_type VARCHAR(30) NOT NULL DEFAULT 'detail'
        CHECK (line_type IN ('title', 'detail', 'subtotal', 'total', 'blank')),

    -- Visualización
    is_foldable BOOLEAN NOT NULL DEFAULT FALSE,
    hide_if_zero BOOLEAN NOT NULL DEFAULT FALSE,
    print_on_new_page BOOLEAN NOT NULL DEFAULT FALSE,

    -- Agrupación dinámica
    groupby_fields TEXT[],                      -- ['partner_id', 'analytic_account_id']

    -- Acción de drill-down
    action_type VARCHAR(50),                    -- 'journal_items', 'partner_ledger', 'custom'
    action_config JSONB,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_line_code_report UNIQUE (code, report_id)
);

CREATE INDEX idx_report_lines_report ON accounting.financial_report_lines (report_id);
CREATE INDEX idx_report_lines_parent ON accounting.financial_report_lines (parent_id);

COMMENT ON TABLE accounting.financial_report_lines IS 'Líneas individuales de reportes financieros';

2.3 Expresiones de Cálculo

-- Expresiones que calculan valores para cada línea
CREATE TABLE accounting.financial_report_expressions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    line_id UUID NOT NULL REFERENCES accounting.financial_report_lines(id) ON DELETE CASCADE,

    -- Identificación
    label VARCHAR(64) NOT NULL,                 -- balance, debit, credit, variance

    -- Motor de cálculo
    engine VARCHAR(30) NOT NULL
        CHECK (engine IN ('account_codes', 'account_types', 'domain', 'aggregation', 'tax_tags', 'external', 'custom')),

    -- Fórmula (interpretación depende del engine)
    formula TEXT NOT NULL,                      -- '1010' (códigos), 'asset_cash' (tipo), 'ASSETS.balance + LIAB.balance' (agregación)
    subformula VARCHAR(50),                     -- 'sum', 'count', 'average', 'sum_if_pos', 'sum_if_neg'

    -- Alcance de fechas
    date_scope VARCHAR(50) NOT NULL DEFAULT 'strict_range'
        CHECK (date_scope IN (
            'from_beginning',           -- Todo el historial
            'from_fiscalyear',          -- Desde inicio año fiscal
            'to_beginning_of_fiscalyear', -- Saldo al inicio del año fiscal
            'to_beginning_of_period',   -- Saldo al inicio del período
            'strict_range'              -- Solo rango de fechas seleccionado
        )),

    -- Formato de salida
    figure_type VARCHAR(30) NOT NULL DEFAULT 'monetary'
        CHECK (figure_type IN ('monetary', 'percentage', 'integer', 'float', 'date', 'string')),

    -- Presentación
    green_on_positive BOOLEAN NOT NULL DEFAULT FALSE,  -- Verde si positivo
    blank_if_zero BOOLEAN NOT NULL DEFAULT FALSE,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_expression_label_line UNIQUE (label, line_id)
);

CREATE INDEX idx_expressions_line ON accounting.financial_report_expressions (line_id);
CREATE INDEX idx_expressions_engine ON accounting.financial_report_expressions (engine);

COMMENT ON TABLE accounting.financial_report_expressions IS 'Expresiones de cálculo para líneas de reporte';

2.4 Columnas de Reporte

-- Definición de columnas del reporte
CREATE TABLE accounting.financial_report_columns (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    report_id UUID NOT NULL REFERENCES accounting.financial_reports(id) ON DELETE CASCADE,

    -- Identificación
    name VARCHAR(255) NOT NULL,                 -- "Período Actual", "Año Anterior"

    -- Expresión a mostrar
    expression_label VARCHAR(64) NOT NULL,      -- 'balance', 'debit', 'credit'

    -- Ajuste de período (para comparativos)
    period_offset INTEGER NOT NULL DEFAULT 0,   -- 0 = actual, -1 = período anterior, -12 = año anterior
    period_offset_type VARCHAR(20) DEFAULT 'month'
        CHECK (period_offset_type IN ('day', 'month', 'quarter', 'year')),

    -- Formato
    figure_type VARCHAR(30) NOT NULL DEFAULT 'monetary',
    blank_if_zero BOOLEAN NOT NULL DEFAULT FALSE,

    -- Orden
    sequence INTEGER NOT NULL DEFAULT 10,

    CONSTRAINT uq_column_report_seq UNIQUE (report_id, sequence)
);

CREATE INDEX idx_columns_report ON accounting.financial_report_columns (report_id);

COMMENT ON TABLE accounting.financial_report_columns IS 'Columnas configurables de reportes';

2.5 Tipos de Cuenta y Mapeo

-- Tipos de cuenta estándar (18 tipos como Odoo)
CREATE TYPE accounting.account_type_enum AS ENUM (
    -- Activos
    'asset_receivable',       -- Cuentas por cobrar
    'asset_cash',             -- Efectivo y bancos
    'asset_current',          -- Activo circulante
    'asset_non_current',      -- Activo no circulante
    'asset_prepayments',      -- Pagos anticipados
    'asset_fixed',            -- Activo fijo

    -- Pasivos
    'liability_payable',      -- Cuentas por pagar
    'liability_credit_card',  -- Tarjeta de crédito
    'liability_current',      -- Pasivo circulante
    'liability_non_current',  -- Pasivo largo plazo

    -- Capital
    'equity',                 -- Capital contable
    'equity_unaffected',      -- Resultado del ejercicio

    -- Ingresos/Gastos
    'income',                 -- Ingresos
    'income_other',           -- Otros ingresos
    'expense',                -- Gastos de operación
    'expense_depreciation',   -- Depreciación
    'expense_direct_cost',    -- Costo de ventas

    -- Otros
    'off_balance'             -- Cuentas de orden
);

-- Mapeo de tipos a secciones de reporte
CREATE TABLE accounting.account_type_report_mapping (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    report_type VARCHAR(50) NOT NULL,           -- balance_sheet, profit_loss
    account_type accounting.account_type_enum NOT NULL,
    report_section VARCHAR(100) NOT NULL,       -- CURRENT_ASSETS, NON_CURRENT_LIABILITIES
    sign_convention INTEGER NOT NULL DEFAULT 1, -- 1 = normal, -1 = invertido

    CONSTRAINT uq_type_mapping UNIQUE (report_type, account_type)
);

-- Insertar mapeo Balance General
INSERT INTO accounting.account_type_report_mapping (report_type, account_type, report_section, sign_convention) VALUES
-- Activos
('balance_sheet', 'asset_receivable', 'CURRENT_ASSETS', 1),
('balance_sheet', 'asset_cash', 'CURRENT_ASSETS', 1),
('balance_sheet', 'asset_current', 'CURRENT_ASSETS', 1),
('balance_sheet', 'asset_prepayments', 'CURRENT_ASSETS', 1),
('balance_sheet', 'asset_non_current', 'NON_CURRENT_ASSETS', 1),
('balance_sheet', 'asset_fixed', 'NON_CURRENT_ASSETS', 1),
-- Pasivos
('balance_sheet', 'liability_payable', 'CURRENT_LIABILITIES', -1),
('balance_sheet', 'liability_credit_card', 'CURRENT_LIABILITIES', -1),
('balance_sheet', 'liability_current', 'CURRENT_LIABILITIES', -1),
('balance_sheet', 'liability_non_current', 'NON_CURRENT_LIABILITIES', -1),
-- Capital
('balance_sheet', 'equity', 'EQUITY', -1),
('balance_sheet', 'equity_unaffected', 'RETAINED_EARNINGS', -1);

-- Insertar mapeo Estado de Resultados
INSERT INTO accounting.account_type_report_mapping (report_type, account_type, report_section, sign_convention) VALUES
-- Ingresos
('profit_loss', 'income', 'REVENUE', -1),
('profit_loss', 'income_other', 'OTHER_INCOME', -1),
-- Costos y Gastos
('profit_loss', 'expense_direct_cost', 'COST_OF_SALES', 1),
('profit_loss', 'expense', 'OPERATING_EXPENSES', 1),
('profit_loss', 'expense_depreciation', 'DEPRECIATION', 1);

COMMENT ON TABLE accounting.account_type_report_mapping IS 'Mapeo de tipos de cuenta a secciones de reportes financieros';

3. Estructura de Reportes Estándar

3.1 Balance General (Estado de Situación Financiera)

ESTADO DE SITUACIÓN FINANCIERA
Al [Fecha de Corte]
Empresa: [Nombre de la Empresa]
(Cifras en [Moneda])

═══════════════════════════════════════════════════════════════════
                                        Período     Período      Var %
                                        Actual      Anterior
═══════════════════════════════════════════════════════════════════

ACTIVO

  Activo Circulante
    Efectivo y Equivalentes             XXX,XXX     XXX,XXX      XX%
    Cuentas por Cobrar                  XXX,XXX     XXX,XXX      XX%
      Clientes Nacionales               XXX,XXX     XXX,XXX
      Clientes Extranjeros              XXX,XXX     XXX,XXX
      Estimación para Cuentas Dudosas  (XXX,XXX)   (XXX,XXX)
    Inventarios                         XXX,XXX     XXX,XXX      XX%
    Pagos Anticipados                   XXX,XXX     XXX,XXX      XX%
    IVA Acreditable                     XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Activo Circulante               XXX,XXX     XXX,XXX      XX%

  Activo No Circulante
    Propiedades, Planta y Equipo        XXX,XXX     XXX,XXX      XX%
      Terrenos                          XXX,XXX     XXX,XXX
      Edificios                         XXX,XXX     XXX,XXX
      Maquinaria y Equipo               XXX,XXX     XXX,XXX
      Depreciación Acumulada           (XXX,XXX)   (XXX,XXX)
    Activos Intangibles                 XXX,XXX     XXX,XXX      XX%
    Inversiones Permanentes             XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Activo No Circulante            XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
TOTAL ACTIVO                            XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

PASIVO

  Pasivo Circulante
    Proveedores                         XXX,XXX     XXX,XXX      XX%
    Acreedores Diversos                 XXX,XXX     XXX,XXX      XX%
    Impuestos por Pagar                 XXX,XXX     XXX,XXX      XX%
    IVA Trasladado                      XXX,XXX     XXX,XXX      XX%
    PTU por Pagar                       XXX,XXX     XXX,XXX      XX%
    Anticipos de Clientes               XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Pasivo Circulante               XXX,XXX     XXX,XXX      XX%

  Pasivo No Circulante
    Deuda a Largo Plazo                 XXX,XXX     XXX,XXX      XX%
    Obligaciones Laborales              XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Pasivo No Circulante            XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
TOTAL PASIVO                            XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

CAPITAL CONTABLE

  Capital Contribuido
    Capital Social                      XXX,XXX     XXX,XXX      XX%
    Aportaciones para Futuros Aumentos  XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Capital Contribuido             XXX,XXX     XXX,XXX      XX%

  Capital Ganado
    Reserva Legal                       XXX,XXX     XXX,XXX      XX%
    Utilidades Retenidas                XXX,XXX     XXX,XXX      XX%
    Resultado del Ejercicio             XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Capital Ganado                  XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
TOTAL CAPITAL CONTABLE                  XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

═══════════════════════════════════════════════════════════════════
TOTAL PASIVO + CAPITAL                  XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

[Verificación: Total Activo = Total Pasivo + Capital]

3.2 Estado de Resultados

ESTADO DE RESULTADOS
Del [Fecha Inicio] al [Fecha Fin]
Empresa: [Nombre de la Empresa]
(Cifras en [Moneda])

═══════════════════════════════════════════════════════════════════
                                        Período     Período      Var %
                                        Actual      Anterior
═══════════════════════════════════════════════════════════════════

INGRESOS

  Ingresos por Ventas
    Ventas Nacionales                   XXX,XXX     XXX,XXX      XX%
    Ventas Exportación                  XXX,XXX     XXX,XXX      XX%
    Devoluciones y Rebajas             (XXX,XXX)   (XXX,XXX)     XX%
  ─────────────────────────────────────────────────────────────────
  Ventas Netas                          XXX,XXX     XXX,XXX      XX%

  Otros Ingresos
    Ingresos por Servicios              XXX,XXX     XXX,XXX      XX%
    Otros Productos                     XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Otros Ingresos                  XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
TOTAL INGRESOS                          XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

COSTO DE VENTAS

  Inventario Inicial                    XXX,XXX     XXX,XXX
  (+) Compras                           XXX,XXX     XXX,XXX
  (-) Inventario Final                 (XXX,XXX)   (XXX,XXX)
  ─────────────────────────────────────────────────────────────────
  Costo de Ventas                       XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
UTILIDAD BRUTA                          XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

GASTOS DE OPERACIÓN

  Gastos de Administración
    Sueldos y Salarios                  XXX,XXX     XXX,XXX      XX%
    Prestaciones                        XXX,XXX     XXX,XXX      XX%
    Honorarios                          XXX,XXX     XXX,XXX      XX%
    Arrendamiento                       XXX,XXX     XXX,XXX      XX%
    Servicios                           XXX,XXX     XXX,XXX      XX%
    Otros Gastos Admón                  XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Gastos Administración           XXX,XXX     XXX,XXX      XX%

  Gastos de Venta
    Comisiones                          XXX,XXX     XXX,XXX      XX%
    Publicidad                          XXX,XXX     XXX,XXX      XX%
    Fletes                              XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  Total Gastos Venta                    XXX,XXX     XXX,XXX      XX%

  Depreciación y Amortización           XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
TOTAL GASTOS DE OPERACIÓN               XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

═══════════════════════════════════════════════════════════════════
UTILIDAD DE OPERACIÓN                   XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

RESULTADO INTEGRAL DE FINANCIAMIENTO

  Ingresos Financieros                  XXX,XXX     XXX,XXX      XX%
  Gastos Financieros                   (XXX,XXX)   (XXX,XXX)     XX%
  Diferencia Cambiaria                  XXX,XXX     XXX,XXX      XX%
  ─────────────────────────────────────────────────────────────────
  RIF Neto                              XXX,XXX     XXX,XXX      XX%

═══════════════════════════════════════════════════════════════════
UTILIDAD ANTES DE IMPUESTOS             XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

  ISR                                  (XXX,XXX)   (XXX,XXX)     XX%
  PTU                                  (XXX,XXX)   (XXX,XXX)     XX%

═══════════════════════════════════════════════════════════════════
UTILIDAD NETA                           XXX,XXX     XXX,XXX      XX%
═══════════════════════════════════════════════════════════════════

4. Motores de Expresión

4.1 Motor account_codes

Suma saldos de cuentas que coinciden con prefijo de código:

interface AccountCodesEngine {
  formula: string;      // Prefijo de código: "101", "1010-1099"
  subformula: 'sum' | 'sum_if_pos' | 'sum_if_neg' | 'count';
}

async function executeAccountCodes(
  formula: string,
  dateScope: DateScope,
  dateRange: DateRange,
  companyId: UUID
): Promise<number> {

  // Parsear rangos (soporta "101" o "101-109")
  const [prefixStart, prefixEnd] = parseCodeRange(formula);

  // Obtener cuentas que coinciden
  const accounts = await db.query(`
    SELECT id FROM accounting.accounts
    WHERE code >= $1
      AND code < $2
      AND company_id = $3
  `, [prefixStart, prefixEnd + 'Z', companyId]);

  // Calcular saldo según date_scope
  const dateFilter = getDateFilter(dateScope, dateRange);

  const result = await db.queryOne(`
    SELECT COALESCE(SUM(debit - credit), 0) AS balance
    FROM accounting.journal_entries je
    JOIN accounting.journal_entry_lines jel ON je.id = jel.entry_id
    WHERE jel.account_id = ANY($1)
      AND je.state = 'posted'
      AND je.company_id = $2
      ${dateFilter.whereClause}
  `, [accounts.map(a => a.id), companyId, ...dateFilter.params]);

  return result.balance;
}

function getDateFilter(scope: DateScope, range: DateRange): QueryFilter {
  switch (scope) {
    case 'from_beginning':
      return { whereClause: 'AND je.date <= $3', params: [range.dateTo] };

    case 'from_fiscalyear':
      const fyStart = getFiscalYearStart(range.dateTo);
      return {
        whereClause: 'AND je.date >= $3 AND je.date <= $4',
        params: [fyStart, range.dateTo]
      };

    case 'to_beginning_of_fiscalyear':
      const fyStartPrev = getFiscalYearStart(range.dateTo);
      return { whereClause: 'AND je.date < $3', params: [fyStartPrev] };

    case 'to_beginning_of_period':
      return { whereClause: 'AND je.date < $3', params: [range.dateFrom] };

    case 'strict_range':
    default:
      return {
        whereClause: 'AND je.date >= $3 AND je.date <= $4',
        params: [range.dateFrom, range.dateTo]
      };
  }
}

4.2 Motor account_types

Suma saldos de cuentas por tipo:

interface AccountTypesEngine {
  formula: string;      // Tipo: "asset_cash", "liability_payable"
  subformula: 'sum' | 'sum_if_pos' | 'sum_if_neg';
}

async function executeAccountTypes(
  formula: string,      // "asset_cash" o "asset_cash,asset_receivable"
  dateScope: DateScope,
  dateRange: DateRange,
  companyId: UUID
): Promise<number> {

  const accountTypes = formula.split(',').map(t => t.trim());
  const dateFilter = getDateFilter(dateScope, dateRange);

  const result = await db.queryOne(`
    SELECT COALESCE(SUM(jel.debit - jel.credit), 0) AS balance
    FROM accounting.journal_entries je
    JOIN accounting.journal_entry_lines jel ON je.id = jel.entry_id
    JOIN accounting.accounts a ON jel.account_id = a.id
    WHERE a.account_type = ANY($1)
      AND je.state = 'posted'
      AND je.company_id = $2
      ${dateFilter.whereClause}
  `, [accountTypes, companyId, ...dateFilter.params]);

  return result.balance;
}

4.3 Motor aggregation

Combina valores de otras líneas:

interface AggregationEngine {
  formula: string;      // "CURRENT_ASSETS.balance + NON_CURRENT_ASSETS.balance"
}

async function executeAggregation(
  formula: string,
  lineValues: Map<string, Map<string, number>>  // lineCode -> expressionLabel -> value
): Promise<number> {

  // Parsear fórmula
  // Soporta: +, -, *, /, sum_children
  const tokens = parseFormula(formula);

  let result = 0;
  let operation = '+';

  for (const token of tokens) {
    if (['+', '-', '*', '/'].includes(token)) {
      operation = token;
      continue;
    }

    // Obtener valor: "CURRENT_ASSETS.balance" -> lineCode.expressionLabel
    const [lineCode, exprLabel] = token.split('.');
    const value = lineValues.get(lineCode)?.get(exprLabel) ?? 0;

    switch (operation) {
      case '+': result += value; break;
      case '-': result -= value; break;
      case '*': result *= value; break;
      case '/': result = value !== 0 ? result / value : 0; break;
    }
  }

  return result;
}

// Función especial: sum_children
async function sumChildren(
  lineId: UUID,
  expressionLabel: string,
  computedValues: Map<UUID, Map<string, number>>
): Promise<number> {

  const children = await getChildLines(lineId);
  return children.reduce((sum, child) => {
    return sum + (computedValues.get(child.id)?.get(expressionLabel) ?? 0);
  }, 0);
}

4.4 Motor domain

Filtra líneas de diario con dominio personalizado:

interface DomainEngine {
  formula: string;      // Dominio en formato JSON
  subformula: 'sum' | 'count' | 'average';
}

async function executeDomain(
  formula: string,      // '[["partner_id.country_id", "=", "MX"]]'
  subformula: string,
  dateScope: DateScope,
  dateRange: DateRange,
  companyId: UUID
): Promise<number> {

  const domain = JSON.parse(formula);
  const dateFilter = getDateFilter(dateScope, dateRange);

  // Convertir dominio a SQL WHERE
  const { whereClause, params } = domainToSql(domain);

  const aggregation = subformula === 'count' ? 'COUNT(*)' :
                      subformula === 'average' ? 'AVG(jel.debit - jel.credit)' :
                      'SUM(jel.debit - jel.credit)';

  const result = await db.queryOne(`
    SELECT COALESCE(${aggregation}, 0) AS value
    FROM accounting.journal_entries je
    JOIN accounting.journal_entry_lines jel ON je.id = jel.entry_id
    WHERE je.state = 'posted'
      AND je.company_id = $1
      ${dateFilter.whereClause}
      ${whereClause}
  `, [companyId, ...dateFilter.params, ...params]);

  return result.value;
}

5. Servicio de Generación de Reportes

5.1 Estructura de Respuesta

interface FinancialReportResult {
  report: {
    id: UUID;
    code: string;
    name: string;
    reportType: string;
  };

  metadata: {
    generatedAt: Date;
    company: { id: UUID; name: string };
    currency: { code: string; symbol: string };
    dateRange: { from: Date; to: Date };
    filters: AppliedFilters;
  };

  columns: ReportColumn[];

  lines: ReportLine[];

  totals: {
    [sectionCode: string]: {
      [columnIndex: number]: number;
    };
  };

  validation?: {
    isBalanced: boolean;  // Para Balance General
    totalAssets: number;
    totalLiabilitiesEquity: number;
    difference: number;
  };
}

interface ReportColumn {
  name: string;
  expressionLabel: string;
  figureType: string;
  periodDescription: string;  // "Dic 2025", "Dic 2024"
}

interface ReportLine {
  id: UUID;
  code: string;
  name: string;
  level: number;
  lineType: 'title' | 'detail' | 'subtotal' | 'total' | 'blank';
  isFoldable: boolean;
  isHidden: boolean;
  values: (number | null)[];  // Un valor por columna
  formattedValues: string[];   // Valores formateados
  children?: ReportLine[];
}

5.2 Servicio Principal

class FinancialReportService {

  /**
   * Genera un reporte financiero completo
   */
  async generateReport(
    reportCode: string,
    options: ReportOptions
  ): Promise<FinancialReportResult> {

    // 1. Cargar definición del reporte
    const report = await this.loadReportDefinition(reportCode, options.companyId);

    // 2. Cargar líneas del reporte
    const lines = await this.loadReportLines(report.id);

    // 3. Cargar columnas
    const columns = await this.loadReportColumns(report.id);

    // 4. Construir contexto de evaluación
    const context = this.buildEvaluationContext(options);

    // 5. Calcular valores para cada línea y columna
    const computedValues = new Map<UUID, Map<string, number>>();

    // Procesar líneas en orden de dependencia (hojas primero, luego padres)
    const sortedLines = this.topologicalSort(lines);

    for (const line of sortedLines) {
      const lineValues = new Map<string, number>();

      for (const expression of line.expressions) {
        const value = await this.evaluateExpression(
          expression,
          context,
          computedValues
        );
        lineValues.set(expression.label, value);
      }

      computedValues.set(line.id, lineValues);
    }

    // 6. Construir resultado
    const result = this.buildResult(report, columns, lines, computedValues, options);

    // 7. Validar (para Balance General)
    if (report.reportType === 'balance_sheet') {
      result.validation = this.validateBalanceSheet(result);
    }

    return result;
  }

  /**
   * Evalúa una expresión según su motor
   */
  private async evaluateExpression(
    expression: ReportExpression,
    context: EvaluationContext,
    computedValues: Map<UUID, Map<string, number>>
  ): Promise<number> {

    // Evaluar para cada columna (período)
    switch (expression.engine) {
      case 'account_codes':
        return this.executeAccountCodes(
          expression.formula,
          expression.dateScope,
          context.dateRange,
          context.companyId
        );

      case 'account_types':
        return this.executeAccountTypes(
          expression.formula,
          expression.dateScope,
          context.dateRange,
          context.companyId
        );

      case 'aggregation':
        return this.executeAggregation(
          expression.formula,
          this.flattenComputedValues(computedValues)
        );

      case 'domain':
        return this.executeDomain(
          expression.formula,
          expression.subformula ?? 'sum',
          expression.dateScope,
          context.dateRange,
          context.companyId
        );

      case 'external':
        return this.getExternalValue(expression.lineId, context.dateRange.dateTo);

      default:
        throw new Error(`Motor de expresión no soportado: ${expression.engine}`);
    }
  }

  /**
   * Valida que el Balance General esté cuadrado
   */
  private validateBalanceSheet(result: FinancialReportResult): BalanceValidation {
    const totalAssets = result.totals['TOTAL_ASSETS']?.[0] ?? 0;
    const totalLiabilities = result.totals['TOTAL_LIABILITIES']?.[0] ?? 0;
    const totalEquity = result.totals['TOTAL_EQUITY']?.[0] ?? 0;

    const totalLiabilitiesEquity = totalLiabilities + totalEquity;
    const difference = Math.abs(totalAssets - totalLiabilitiesEquity);

    return {
      isBalanced: difference < 0.01,  // Tolerancia por redondeo
      totalAssets,
      totalLiabilitiesEquity,
      difference
    };
  }
}

6. API REST

6.1 Endpoints

GET /api/v1/reports/financial/:reportCode

Genera y retorna un reporte financiero.

Query Parameters:

Parámetro Tipo Requerido Descripción
dateFrom date Fecha inicio
dateTo date Fecha fin
companyId uuid Empresa
comparison string No 'previous_period', 'previous_year'
journalIds uuid[] No Filtrar por diarios
analyticIds uuid[] No Filtrar por cuentas analíticas
partnerIds uuid[] No Filtrar por socios
showDraft boolean No Incluir borrador (default: false)
hideZero boolean No Ocultar ceros (default: true)
hierarchy boolean No Mostrar grupos (default: true)

Response:

{
  "report": {
    "id": "uuid",
    "code": "balance_sheet",
    "name": "Balance General",
    "reportType": "balance_sheet"
  },
  "metadata": {
    "generatedAt": "2025-12-08T10:30:00Z",
    "company": { "id": "uuid", "name": "Mi Empresa SA de CV" },
    "currency": { "code": "MXN", "symbol": "$" },
    "dateRange": { "from": "2025-01-01", "to": "2025-12-31" }
  },
  "columns": [
    { "name": "Dic 2025", "expressionLabel": "balance", "figureType": "monetary" },
    { "name": "Dic 2024", "expressionLabel": "balance", "figureType": "monetary" },
    { "name": "Var %", "expressionLabel": "variance_pct", "figureType": "percentage" }
  ],
  "lines": [
    {
      "id": "uuid",
      "code": "ASSETS",
      "name": "ACTIVO",
      "level": 0,
      "lineType": "title",
      "values": [null, null, null],
      "children": [
        {
          "id": "uuid",
          "code": "CURRENT_ASSETS",
          "name": "Activo Circulante",
          "level": 1,
          "lineType": "subtotal",
          "values": [1500000.00, 1200000.00, 25.00],
          "formattedValues": ["$1,500,000.00", "$1,200,000.00", "25.00%"],
          "children": [...]
        }
      ]
    }
  ],
  "validation": {
    "isBalanced": true,
    "totalAssets": 5000000.00,
    "totalLiabilitiesEquity": 5000000.00,
    "difference": 0.00
  }
}

GET /api/v1/reports/financial/:reportCode/export

Exporta el reporte en formato PDF, Excel o CSV.

Query Parameters: (Mismos que GET + formato)

Parámetro Tipo Opciones
format string 'pdf', 'xlsx', 'csv'

Response: Archivo binario con Content-Type apropiado.

GET /api/v1/reports/financial

Lista reportes disponibles.

Response:

{
  "reports": [
    {
      "id": "uuid",
      "code": "balance_sheet",
      "name": "Balance General",
      "reportType": "balance_sheet",
      "countryCode": "MX"
    },
    {
      "id": "uuid",
      "code": "profit_loss",
      "name": "Estado de Resultados",
      "reportType": "profit_loss",
      "countryCode": "MX"
    }
  ]
}

7. Configuración para México (SAT)

7.1 Plan de Cuentas SAT

-- Grupos de cuentas según agrupador SAT
INSERT INTO accounting.account_groups (code, name, parent_code) VALUES
-- Activos
('1', 'Activo', NULL),
('100', 'Activo a Corto Plazo', '1'),
('101', 'Caja', '100'),
('102', 'Bancos', '100'),
('105', 'Clientes', '100'),
('115', 'Inventarios', '100'),
('118', 'IVA Acreditable', '100'),
('120', 'Activo a Largo Plazo', '1'),
('150', 'Activo Fijo', '1'),
('151', 'Terrenos', '150'),
('152', 'Edificios', '150'),
('153', 'Maquinaria y Equipo', '150'),
('154', 'Equipo de Transporte', '150'),
('155', 'Equipo de Cómputo', '150'),
('156', 'Depreciación Acumulada', '150'),

-- Pasivos
('2', 'Pasivo', NULL),
('200', 'Pasivo a Corto Plazo', '2'),
('201', 'Proveedores', '200'),
('205', 'Acreedores Diversos', '200'),
('208', 'IVA Trasladado', '200'),
('210', 'Impuestos por Pagar', '200'),
('213', 'Anticipos de Clientes', '200'),
('250', 'Pasivo a Largo Plazo', '2'),

-- Capital
('3', 'Capital', NULL),
('301', 'Capital Social', '3'),
('302', 'Utilidades Retenidas', '3'),
('304', 'Resultado del Ejercicio', '3'),

-- Ingresos
('4', 'Ingresos', NULL),
('401', 'Ventas', '4'),
('402', 'Otros Ingresos', '4'),

-- Costos
('5', 'Costos', NULL),
('501', 'Costo de Ventas', '5'),

-- Gastos
('6', 'Gastos', NULL),
('601', 'Gastos de Administración', '6'),
('602', 'Gastos de Venta', '6'),
('610', 'Gastos Financieros', '6');

7.2 Configuración de Signo (Saldo Normal)

-- Cuentas con saldo deudor normal (códigos 1, 5, 6, 7)
-- Cuentas con saldo acreedor normal (códigos 2, 3, 4)

-- El signo determina cómo mostrar en reportes
-- Activos: saldo deudor positivo = positivo en reporte
-- Pasivos: saldo acreedor positivo = positivo en reporte (invierte signo)

CREATE TABLE accounting.account_sign_convention (
    account_type accounting.account_type_enum PRIMARY KEY,
    normal_balance VARCHAR(10) NOT NULL CHECK (normal_balance IN ('debit', 'credit')),
    report_sign INTEGER NOT NULL DEFAULT 1  -- 1 = como está, -1 = invertir
);

INSERT INTO accounting.account_sign_convention VALUES
('asset_receivable', 'debit', 1),
('asset_cash', 'debit', 1),
('asset_current', 'debit', 1),
('asset_non_current', 'debit', 1),
('asset_prepayments', 'debit', 1),
('asset_fixed', 'debit', 1),
('liability_payable', 'credit', -1),
('liability_credit_card', 'credit', -1),
('liability_current', 'credit', -1),
('liability_non_current', 'credit', -1),
('equity', 'credit', -1),
('equity_unaffected', 'credit', -1),
('income', 'credit', -1),
('income_other', 'credit', -1),
('expense', 'debit', 1),
('expense_depreciation', 'debit', 1),
('expense_direct_cost', 'debit', 1);

8. Exportación

8.1 Generador PDF

class FinancialReportPdfGenerator {

  async generate(report: FinancialReportResult): Promise<Buffer> {
    const template = await this.loadTemplate(report.report.reportType);

    const html = await this.renderTemplate(template, {
      report,
      company: report.metadata.company,
      formatNumber: (n: number) => this.formatCurrency(n, report.metadata.currency),
      formatDate: (d: Date) => this.formatDate(d)
    });

    return this.htmlToPdf(html, {
      format: 'Letter',
      margin: { top: '1in', bottom: '1in', left: '0.75in', right: '0.75in' },
      headerTemplate: this.getHeader(report),
      footerTemplate: this.getFooter()
    });
  }

  private getHeader(report: FinancialReportResult): string {
    return `
      <div style="font-size:10px; width:100%; text-align:center;">
        <strong>${report.metadata.company.name}</strong><br>
        ${report.report.name}<br>
        ${this.formatDateRange(report.metadata.dateRange)}
      </div>
    `;
  }
}

8.2 Generador Excel

class FinancialReportExcelGenerator {

  async generate(report: FinancialReportResult): Promise<Buffer> {
    const workbook = new ExcelJS.Workbook();
    const sheet = workbook.addWorksheet(report.report.name);

    // Encabezado
    sheet.mergeCells('A1:' + this.getLastColumn(report.columns.length) + '1');
    sheet.getCell('A1').value = report.metadata.company.name;
    sheet.getCell('A1').font = { bold: true, size: 14 };

    sheet.mergeCells('A2:' + this.getLastColumn(report.columns.length) + '2');
    sheet.getCell('A2').value = report.report.name;

    sheet.mergeCells('A3:' + this.getLastColumn(report.columns.length) + '3');
    sheet.getCell('A3').value = this.formatDateRange(report.metadata.dateRange);

    // Columnas
    const headerRow = sheet.getRow(5);
    headerRow.values = ['Cuenta', ...report.columns.map(c => c.name)];
    headerRow.font = { bold: true };
    headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };

    // Datos
    let rowIndex = 6;
    for (const line of this.flattenLines(report.lines)) {
      const row = sheet.getRow(rowIndex++);
      const indent = '  '.repeat(line.level);

      row.values = [
        indent + line.name,
        ...line.values.map((v, i) =>
          v !== null ? this.formatValue(v, report.columns[i].figureType) : ''
        )
      ];

      // Estilo según tipo de línea
      if (line.lineType === 'title' || line.lineType === 'total') {
        row.font = { bold: true };
      }
      if (line.lineType === 'total') {
        row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF0F0F0' } };
      }
    }

    // Ajustar anchos
    sheet.columns.forEach((col, i) => {
      col.width = i === 0 ? 40 : 18;
    });

    return workbook.xlsx.writeBuffer();
  }
}

9. Drill-Down y Navegación

9.1 Configuración de Acciones

interface DrillDownAction {
  type: 'journal_items' | 'partner_ledger' | 'analytic_report' | 'custom';
  config: {
    targetReport?: string;
    filters?: Record<string, any>;
  };
}

// Configuración en línea de reporte
const lineConfig = {
  code: 'ACCOUNTS_RECEIVABLE',
  name: 'Cuentas por Cobrar',
  action: {
    type: 'journal_items',
    config: {
      filters: {
        accountTypes: ['asset_receivable'],
        unreconciled: true
      }
    }
  }
};

9.2 Endpoint de Drill-Down

GET /api/v1/reports/financial/:reportCode/lines/:lineCode/drilldown

Response: Lista de asientos/movimientos que componen la línea

10. Consideraciones de Performance

10.1 Estrategias de Optimización

  1. Materializar saldos por período: Pre-calcular saldos mensuales
  2. Índices compuestos: account_id + date + state
  3. Cache de reportes: Almacenar reportes generados por período cerrado
  4. Cálculo paralelo: Procesar expresiones independientes en paralelo

10.2 Índices Recomendados

-- Índice para cálculo de saldos
CREATE INDEX idx_journal_lines_balance ON accounting.journal_entry_lines (
    account_id,
    company_id
) INCLUDE (debit, credit);

-- Índice para filtro de fechas
CREATE INDEX idx_journal_entries_date ON accounting.journal_entries (
    company_id,
    date,
    state
) WHERE state = 'posted';

-- Índice para búsqueda por código de cuenta
CREATE INDEX idx_accounts_code ON accounting.accounts (
    code,
    company_id
);

11. Testing

11.1 Casos de Prueba

  1. Balance Cuadrado: Total Activo = Total Pasivo + Capital
  2. Comparativo correcto: Variación % calculada correctamente
  3. Filtros funcionan: Por diario, analítica, socio
  4. Multi-moneda: Conversión correcta
  5. Períodos fiscales: Respeta año fiscal configurado
  6. Exportación: PDF, Excel, CSV generan correctamente

11.2 Datos de Prueba

-- Crear asientos de prueba
INSERT INTO accounting.journal_entries (company_id, date, state) VALUES
('uuid-company', '2025-01-15', 'posted'),
('uuid-company', '2025-02-20', 'posted');

-- Líneas de asiento
INSERT INTO accounting.journal_entry_lines (entry_id, account_id, debit, credit) VALUES
('uuid-entry-1', 'uuid-cash', 10000, 0),       -- Debit Cash
('uuid-entry-1', 'uuid-sales', 0, 10000);      -- Credit Sales

12. Referencias

  • Odoo Source: addons/account/models/account_report.py
  • NIF B-3: Normas de Información Financiera - Estado de Resultado Integral
  • NIF B-6: Normas de Información Financiera - Estado de Situación Financiera
  • SAT: Agrupador del Catálogo de Cuentas

Historial de Cambios

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