46 KiB
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 | Sí | Fecha inicio |
dateTo |
date | Sí | Fecha fin |
companyId |
uuid | Sí | 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
- Materializar saldos por período: Pre-calcular saldos mensuales
- Índices compuestos: account_id + date + state
- Cache de reportes: Almacenar reportes generados por período cerrado
- 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
- Balance Cuadrado: Total Activo = Total Pasivo + Capital
- Comparativo correcto: Variación % calculada correctamente
- Filtros funcionan: Por diario, analítica, socio
- Multi-moneda: Conversión correcta
- Períodos fiscales: Respeta año fiscal configurado
- 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 |