# 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 ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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: ```typescript 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 { // 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: ```typescript 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 { 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: ```typescript interface AggregationEngine { formula: string; // "CURRENT_ASSETS.balance + NON_CURRENT_ASSETS.balance" } async function executeAggregation( formula: string, lineValues: Map> // lineCode -> expressionLabel -> value ): Promise { // 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> ): Promise { 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: ```typescript 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 { 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 ```typescript 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 ```typescript class FinancialReportService { /** * Genera un reporte financiero completo */ async generateReport( reportCode: string, options: ReportOptions ): Promise { // 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>(); // 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(); 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> ): Promise { // 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:** ```json { "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:** ```json { "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 ```sql -- 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) ```sql -- 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 ```typescript class FinancialReportPdfGenerator { async generate(report: FinancialReportResult): Promise { 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 `
${report.metadata.company.name}
${report.report.name}
${this.formatDateRange(report.metadata.dateRange)}
`; } } ``` ### 8.2 Generador Excel ```typescript class FinancialReportExcelGenerator { async generate(report: FinancialReportResult): Promise { 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 ```typescript interface DrillDownAction { type: 'journal_items' | 'partner_ledger' | 'analytic_report' | 'custom'; config: { targetReport?: string; filters?: Record; }; } // 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 ```sql -- Í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 ```sql -- 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 |