# Especificación Técnica: Contabilidad Analítica Multi-Dimensional **Código:** SPEC-TRANS-005 **Versión:** 1.0 **Fecha:** 2025-12-08 **Estado:** Especificado **Basado en:** Odoo account.analytic.plan (v18.0) --- ## 1. Resumen Ejecutivo ### 1.1 Propósito El Sistema de Contabilidad Analítica Multi-Dimensional permite distribuir costos e ingresos a través de múltiples dimensiones de análisis (proyectos, departamentos, centros de costo, categorías) simultáneamente, habilitando reportes de rentabilidad desde múltiples perspectivas. ### 1.2 Alcance - **Planes Analíticos**: Definición de dimensiones de análisis (Proyectos, Departamentos, etc.) - **Cuentas Analíticas**: Elementos específicos dentro de cada plan - **Distribución Multi-dimensional**: Asignar transacciones a múltiples cuentas de diferentes planes - **Porcentajes flexibles**: 60% Proyecto A + 40% Proyecto B AND 100% Departamento X - **Modelos de Distribución**: Templates automáticos basados en reglas - **Aplicabilidad**: Planes obligatorios vs opcionales por tipo de documento ### 1.3 Módulos Afectados | Módulo | Rol | |--------|-----| | MGN-008 (Analítica) | Core del sistema analítico | | MGN-004 (Financiero) | Integración con asientos contables | | MGN-011 (Proyectos) | Plan de proyectos | | MGN-006 (Compras) | Distribución en facturas de compra | | MGN-007 (Ventas) | Distribución en facturas de venta | ### 1.4 Caso de Uso Principal - Construcción ``` Compra de cemento: $100,000 ├── Distribución por Proyecto: │ ├── Torre A: 60% ($60,000) │ └── Torre B: 40% ($40,000) ├── Distribución por Departamento: │ └── Construcción: 100% ($100,000) └── Distribución por Categoría: └── Materiales: 100% ($100,000) Resultado: 2 líneas analíticas ├── Línea 1: Torre A + Construcción + Materiales = $60,000 └── Línea 2: Torre B + Construcción + Materiales = $40,000 ``` --- ## 2. Modelo de Datos ### 2.1 Planes Analíticos ```sql -- Definición de dimensiones de análisis CREATE TABLE analytics.plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(255) NOT NULL, code VARCHAR(64), description TEXT, -- Jerarquía de planes parent_id UUID REFERENCES analytics.plans(id), parent_path VARCHAR(255), -- Materializado: /1/2/3/ root_id UUID REFERENCES analytics.plans(id), -- Plan raíz -- Aplicabilidad por defecto default_applicability VARCHAR(20) NOT NULL DEFAULT 'optional' CHECK (default_applicability IN ('optional', 'mandatory', 'unavailable')), -- Presentación color INTEGER DEFAULT 0, sequence INTEGER NOT NULL DEFAULT 10, -- Multi-tenant company_id UUID REFERENCES core_auth.companies(id), tenant_id UUID NOT NULL, -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_plan_code_tenant UNIQUE (code, tenant_id) ); -- Índices CREATE INDEX idx_plans_parent ON analytics.plans (parent_id); CREATE INDEX idx_plans_root ON analytics.plans (root_id); CREATE INDEX idx_plans_path ON analytics.plans USING GIST (parent_path gist_trgm_ops); COMMENT ON TABLE analytics.plans IS 'Planes analíticos (dimensiones de análisis)'; COMMENT ON COLUMN analytics.plans.parent_path IS 'Path materializado para consultas jerárquicas'; ``` ### 2.2 Cuentas Analíticas ```sql -- Cuentas dentro de cada plan CREATE TABLE analytics.accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(255) NOT NULL, code VARCHAR(64), description TEXT, -- Plan al que pertenece plan_id UUID NOT NULL REFERENCES analytics.plans(id) ON DELETE RESTRICT, root_plan_id UUID REFERENCES analytics.plans(id), -- Calculado del plan -- Asociaciones partner_id UUID REFERENCES core_catalogs.partners(id), -- Cliente/Proveedor asociado project_id UUID REFERENCES projects.projects(id), -- Proyecto asociado -- Saldos (calculados) balance DECIMAL(16,4) NOT NULL DEFAULT 0, debit DECIMAL(16,4) NOT NULL DEFAULT 0, credit DECIMAL(16,4) NOT NULL DEFAULT 0, -- Multi-tenant company_id UUID REFERENCES core_auth.companies(id), tenant_id UUID NOT NULL, -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_account_code_plan UNIQUE (code, plan_id, tenant_id) ); -- Índices CREATE INDEX idx_analytic_accounts_plan ON analytics.accounts (plan_id); CREATE INDEX idx_analytic_accounts_root_plan ON analytics.accounts (root_plan_id); CREATE INDEX idx_analytic_accounts_partner ON analytics.accounts (partner_id); CREATE INDEX idx_analytic_accounts_project ON analytics.accounts (project_id); COMMENT ON TABLE analytics.accounts IS 'Cuentas analíticas individuales dentro de planes'; ``` ### 2.3 Líneas Analíticas ```sql -- Entradas analíticas (detalle de distribuciones) CREATE TABLE analytics.lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Descripción name VARCHAR(500), date DATE NOT NULL, -- Montos amount DECIMAL(16,4) NOT NULL, unit_amount DECIMAL(16,4), -- Cantidad currency_id UUID NOT NULL REFERENCES core_catalogs.currencies(id), -- Referencias partner_id UUID REFERENCES core_catalogs.partners(id), product_id UUID REFERENCES inventory.products(id), user_id UUID REFERENCES core_auth.users(id), -- Conexión con contabilidad move_line_id UUID REFERENCES accounting.journal_entry_lines(id) ON DELETE CASCADE, general_account_id UUID REFERENCES accounting.accounts(id), -- CUENTAS ANALÍTICAS POR PLAN (dinámicas) -- El plan "Proyectos" usa account_id (columna principal) account_id UUID REFERENCES analytics.accounts(id), -- Otros planes usan columnas dinámicas: plan_{id}_account_id -- Estas se crean al agregar nuevos planes (ver sección 4) -- Multi-tenant company_id UUID NOT NULL REFERENCES core_auth.companies(id), tenant_id UUID NOT NULL, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES core_auth.users(id) ); -- Índices principales CREATE INDEX idx_analytic_lines_date ON analytics.lines (date); CREATE INDEX idx_analytic_lines_account ON analytics.lines (account_id); CREATE INDEX idx_analytic_lines_move ON analytics.lines (move_line_id); CREATE INDEX idx_analytic_lines_account_date ON analytics.lines (account_id, date); COMMENT ON TABLE analytics.lines IS 'Líneas analíticas - detalle de distribuciones'; COMMENT ON COLUMN analytics.lines.account_id IS 'Cuenta analítica del plan principal (Proyectos)'; ``` ### 2.4 Distribución Analítica (Campo JSON) ```sql -- El campo analytic_distribution se agrega a múltiples tablas -- Es un JSON con formato: { "account_id1,account_id2": percentage, ... } -- Agregar a líneas de factura ALTER TABLE accounting.journal_entry_lines ADD COLUMN IF NOT EXISTS analytic_distribution JSONB; -- Agregar a líneas de orden de compra ALTER TABLE purchasing.purchase_order_lines ADD COLUMN IF NOT EXISTS analytic_distribution JSONB; -- Agregar a líneas de orden de venta ALTER TABLE sales.sale_order_lines ADD COLUMN IF NOT EXISTS analytic_distribution JSONB; -- Índice GIN para búsquedas en distribución CREATE INDEX idx_jel_analytic_dist ON accounting.journal_entry_lines USING GIN (analytic_distribution); COMMENT ON COLUMN accounting.journal_entry_lines.analytic_distribution IS 'Distribución analítica JSON: {"account_id1,account_id2": percentage, ...}'; ``` ### 2.5 Modelos de Distribución (Templates) ```sql -- Templates para auto-poblar distribución analítica CREATE TABLE analytics.distribution_models ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Distribución template analytic_distribution JSONB NOT NULL, -- Criterios de matching (todos opcionales, más específico = mayor prioridad) partner_id UUID REFERENCES core_catalogs.partners(id), partner_category_id UUID REFERENCES core_catalogs.partner_categories(id), product_category_id UUID REFERENCES inventory.product_categories(id), account_prefix VARCHAR(20), -- Prefijo de cuenta contable (ej: '61,62') -- Orden de aplicación sequence INTEGER NOT NULL DEFAULT 10, -- Multi-tenant company_id UUID REFERENCES core_auth.companies(id), tenant_id UUID NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_dist_models_partner ON analytics.distribution_models (partner_id); CREATE INDEX idx_dist_models_sequence ON analytics.distribution_models (sequence); COMMENT ON TABLE analytics.distribution_models IS 'Templates de distribución analítica automática'; ``` ### 2.6 Reglas de Aplicabilidad ```sql -- Define cuándo un plan es obligatorio/opcional/no disponible CREATE TABLE analytics.plan_applicability ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), plan_id UUID NOT NULL REFERENCES analytics.plans(id) ON DELETE CASCADE, -- Contexto de aplicación business_domain VARCHAR(50) NOT NULL CHECK (business_domain IN ('invoice', 'bill', 'general', 'expense', 'timesheet')), -- Criterios adicionales account_prefix VARCHAR(50), -- Prefijos de cuenta separados por coma product_category_id UUID REFERENCES inventory.product_categories(id), -- Nivel de requerimiento applicability VARCHAR(20) NOT NULL DEFAULT 'optional' CHECK (applicability IN ('optional', 'mandatory', 'unavailable')), -- Multi-tenant company_id UUID REFERENCES core_auth.companies(id), tenant_id UUID NOT NULL, sequence INTEGER NOT NULL DEFAULT 10, CONSTRAINT uq_applicability UNIQUE (plan_id, business_domain, company_id, tenant_id) ); CREATE INDEX idx_applicability_plan ON analytics.plan_applicability (plan_id); CREATE INDEX idx_applicability_domain ON analytics.plan_applicability (business_domain); COMMENT ON TABLE analytics.plan_applicability IS 'Reglas de cuándo un plan es obligatorio'; ``` --- ## 3. Formato de Distribución JSON ### 3.1 Estructura ```typescript /** * Formato del campo analytic_distribution * * Keys: IDs de cuentas analíticas separados por coma (de diferentes planes) * Values: Porcentaje asignado (0-100) */ type AnalyticDistribution = { [accountIds: string]: number; // "uuid1,uuid2": 60 }; // Ejemplos: // Distribución simple (un solo plan) const simple: AnalyticDistribution = { "project-a-uuid": 60, "project-b-uuid": 40 }; // Distribución multi-dimensional const multiDim: AnalyticDistribution = { "project-a-uuid,dept-construction-uuid": 60, "project-b-uuid,dept-construction-uuid": 40 }; // Distribución compleja (3 dimensiones) const complex: AnalyticDistribution = { "proj-a,dept-x,cat-materials": 30, "proj-a,dept-x,cat-labor": 30, "proj-b,dept-x,cat-materials": 20, "proj-b,dept-x,cat-labor": 20 }; ``` ### 3.2 Reglas de Validación ```typescript interface DistributionValidation { // Por cada plan raíz, la suma debe ser 100% (si es mandatory) // O puede ser < 100% (si es optional) planTotals: Map; // planId -> totalPercentage // Ejemplo válido: // Plan Proyectos: 60% + 40% = 100% ✓ // Plan Departamentos: 100% ✓ // Plan Categorías: 100% ✓ // Ejemplo inválido (plan mandatory): // Plan Proyectos: 60% + 30% = 90% ✗ (debe ser 100%) } function validateDistribution( distribution: AnalyticDistribution, mandatoryPlans: UUID[] ): ValidationResult { const planTotals = new Map(); for (const [accountIds, percentage] of Object.entries(distribution)) { const accounts = await getAccounts(accountIds.split(',')); for (const account of accounts) { const rootPlanId = account.root_plan_id; const current = planTotals.get(rootPlanId) || 0; planTotals.set(rootPlanId, current + percentage); } } // Validar planes obligatorios for (const planId of mandatoryPlans) { const total = planTotals.get(planId) || 0; if (Math.abs(total - 100) > 0.01) { return { valid: false, error: `Plan ${planId} requiere distribución del 100%, tiene ${total}%` }; } } return { valid: true }; } ``` --- ## 4. Columnas Dinámicas por Plan ### 4.1 Estrategia de Columnas Cuando se crea un nuevo plan analítico, se agrega una columna FK a la tabla `analytics.lines`: ```typescript async function onPlanCreated(plan: AnalyticPlan): Promise { // El primer plan (Proyectos) usa la columna account_id existente if (isFirstPlan(plan)) { return; } // Otros planes crean columna dinámica const columnName = `plan_${plan.id}_account_id`; await db.query(` ALTER TABLE analytics.lines ADD COLUMN IF NOT EXISTS ${columnName} UUID REFERENCES analytics.accounts(id); CREATE INDEX IF NOT EXISTS idx_analytic_lines_${plan.id} ON analytics.lines (${columnName}) WHERE ${columnName} IS NOT NULL; `); } ``` ### 4.2 Mapeo de Columnas ```typescript interface PlanColumnMapping { planId: UUID; columnName: string; isMainColumn: boolean; } function getPlanColumnName(plan: AnalyticPlan): string { // El plan "Proyectos" (configurado en settings) usa account_id const projectPlanId = await getSetting('analytic.project_plan_id'); if (plan.root_id === projectPlanId || plan.id === projectPlanId) { return 'account_id'; } // Otros planes usan columna dinámica return `plan_${plan.root_id}_account_id`; } ``` --- ## 5. Servicio de Distribución Analítica ### 5.1 Crear Líneas Analíticas ```typescript interface CreateAnalyticLinesParams { moveLineId: UUID; distribution: AnalyticDistribution; amount: number; date: Date; companyId: UUID; partnerId?: UUID; productId?: UUID; } class AnalyticDistributionService { /** * Crea líneas analíticas a partir de una distribución */ async createAnalyticLines(params: CreateAnalyticLinesParams): Promise { const { distribution, amount, date, companyId, moveLineId } = params; // 1. Validar distribución const mandatoryPlans = await this.getMandatoryPlans(params); const validation = await this.validateDistribution(distribution, mandatoryPlans); if (!validation.valid) { throw new ValidationError(validation.error); } // 2. Preparar líneas analíticas const lineValues: AnalyticLineInput[] = []; const distributionByPlan = new Map(); for (const [accountIdsStr, percentage] of Object.entries(distribution)) { const accountIds = accountIdsStr.split(','); const accounts = await this.getAccounts(accountIds); // Calcular monto para esta combinación const lineAmount = this.calculateAmount(amount, percentage, distributionByPlan, accounts); // Preparar valores de línea const lineValue: AnalyticLineInput = { name: await this.getDescription(moveLineId), date, amount: lineAmount, move_line_id: moveLineId, company_id: companyId, partner_id: params.partnerId, product_id: params.productId }; // Asignar cuenta a columna correcta según plan for (const account of accounts) { const columnName = getPlanColumnName(account.plan); lineValue[columnName] = account.id; } lineValues.push(lineValue); } // 3. Distribuir errores de redondeo this.distributeRoundingError(lineValues, amount); // 4. Crear líneas en batch return this.createLines(lineValues); } /** * Calcula el monto para una línea considerando progreso por plan */ private calculateAmount( totalAmount: number, percentage: number, distributionByPlan: Map, accounts: AnalyticAccount[] ): number { // Obtener plan raíz de las cuentas const rootPlan = accounts[0].root_plan_id; // Acumular porcentaje por plan const previousTotal = distributionByPlan.get(rootPlan) || 0; const newTotal = previousTotal + percentage; distributionByPlan.set(rootPlan, newTotal); // Si llegamos a 100%, calcular como diferencia (evita errores de redondeo) if (Math.abs(newTotal - 100) < 0.01) { const previousAmount = totalAmount * previousTotal / 100; return -(totalAmount - previousAmount); } return -(totalAmount * percentage / 100); } /** * Distribuye errores de redondeo entre líneas */ private distributeRoundingError(lines: AnalyticLineInput[], expectedTotal: number): void { const actualTotal = lines.reduce((sum, l) => sum + l.amount, 0); const error = expectedTotal + actualTotal; // amount es negativo if (Math.abs(error) > 0.001 && lines.length > 0) { // Distribuir error en la última línea lines[lines.length - 1].amount -= error; } } } ``` ### 5.2 Obtener Distribución desde Modelos ```typescript interface DistributionContext { partnerId?: UUID; partnerCategoryId?: UUID; productCategoryId?: UUID; accountCode?: string; companyId: UUID; } class DistributionModelService { /** * Obtiene distribución automática basada en modelos configurados */ async getDistribution(context: DistributionContext): Promise { // 1. Buscar modelos que coincidan con el contexto const models = await this.findMatchingModels(context); if (models.length === 0) { return null; } // 2. Mergear distribuciones (por secuencia, evitando duplicados por plan) const mergedDistribution: AnalyticDistribution = {}; const appliedPlans = new Set(); for (const model of models) { for (const [accountIds, percentage] of Object.entries(model.analytic_distribution)) { const accounts = await this.getAccounts(accountIds.split(',')); // Solo aplicar si el plan no fue aplicado antes const planIds = accounts.map(a => a.root_plan_id); const allNew = planIds.every(pid => !appliedPlans.has(pid)); if (allNew) { mergedDistribution[accountIds] = percentage; planIds.forEach(pid => appliedPlans.add(pid)); } } } return Object.keys(mergedDistribution).length > 0 ? mergedDistribution : null; } /** * Busca modelos ordenados por relevancia (más específico primero) */ private async findMatchingModels(context: DistributionContext): Promise { // Construir query con scoring const models = await db.query(` SELECT dm.*, (CASE WHEN dm.partner_id = $1 THEN 4 ELSE 0 END) + (CASE WHEN dm.partner_category_id = $2 THEN 2 ELSE 0 END) + (CASE WHEN dm.product_category_id = $3 THEN 2 ELSE 0 END) + (CASE WHEN $4 LIKE dm.account_prefix || '%' THEN 1 ELSE 0 END) + (CASE WHEN dm.company_id = $5 THEN 0.5 ELSE 0 END) AS score FROM analytics.distribution_models dm WHERE dm.is_active = TRUE AND dm.tenant_id = $6 AND (dm.company_id IS NULL OR dm.company_id = $5) AND ( dm.partner_id = $1 OR dm.partner_id IS NULL ) AND ( dm.partner_category_id = $2 OR dm.partner_category_id IS NULL ) AND ( dm.product_category_id = $3 OR dm.product_category_id IS NULL ) AND ( dm.account_prefix IS NULL OR $4 LIKE dm.account_prefix || '%' ) HAVING score > 0 ORDER BY score DESC, dm.sequence ASC `, [ context.partnerId, context.partnerCategoryId, context.productCategoryId, context.accountCode, context.companyId, context.tenantId ]); return models; } } ``` ### 5.3 Validar Planes Obligatorios ```typescript class PlanApplicabilityService { /** * Obtiene planes relevantes para un contexto */ async getRelevantPlans(context: { businessDomain: 'invoice' | 'bill' | 'general' | 'expense' | 'timesheet'; accountCode?: string; productCategoryId?: UUID; companyId: UUID; }): Promise { const plans = await db.query(` SELECT p.id AS plan_id, p.name AS plan_name, COALESCE(pa.applicability, p.default_applicability) AS applicability, ( CASE WHEN pa.company_id IS NOT NULL THEN 0.5 ELSE 0 END + CASE WHEN pa.account_prefix IS NOT NULL AND $2 LIKE pa.account_prefix || '%' THEN 1 ELSE 0 END + CASE WHEN pa.product_category_id = $3 THEN 1 ELSE 0 END ) AS score FROM analytics.plans p LEFT JOIN analytics.plan_applicability pa ON pa.plan_id = p.id AND pa.business_domain = $1 AND (pa.company_id IS NULL OR pa.company_id = $4) WHERE p.is_active = TRUE AND p.tenant_id = $5 AND p.parent_id IS NULL -- Solo planes raíz ORDER BY p.sequence, score DESC `, [ context.businessDomain, context.accountCode, context.productCategoryId, context.companyId, context.tenantId ]); // Filtrar por mejor score para cada plan const bestByPlan = new Map(); for (const plan of plans) { if (!bestByPlan.has(plan.plan_id) || plan.score > bestByPlan.get(plan.plan_id)!.score) { bestByPlan.set(plan.plan_id, plan); } } return Array.from(bestByPlan.values()) .filter(p => p.applicability !== 'unavailable'); } /** * Obtiene IDs de planes obligatorios */ async getMandatoryPlanIds(context: ApplicabilityContext): Promise { const plans = await this.getRelevantPlans(context); return plans .filter(p => p.applicability === 'mandatory') .map(p => p.plan_id); } } ``` --- ## 6. Integración con Asientos Contables ### 6.1 Hook en Publicación de Factura ```typescript class JournalEntryService { /** * Crea líneas analíticas al publicar asiento */ async postEntry(entryId: UUID): Promise { const entry = await this.getEntry(entryId); // Validar distribuciones obligatorias await this.validateAnalyticDistributions(entry); // Crear líneas analíticas for (const line of entry.lines) { if (line.analytic_distribution && Object.keys(line.analytic_distribution).length > 0) { await this.analyticService.createAnalyticLines({ moveLineId: line.id, distribution: line.analytic_distribution, amount: line.balance, date: entry.date, companyId: entry.company_id, partnerId: line.partner_id, productId: line.product_id }); } } // Actualizar estado await this.updateState(entryId, 'posted'); } /** * Valida que planes obligatorios tengan distribución 100% */ private async validateAnalyticDistributions(entry: JournalEntry): Promise { for (const line of entry.lines) { if (!line.analytic_distribution) continue; const mandatoryPlans = await this.applicabilityService.getMandatoryPlanIds({ businessDomain: this.getBusinessDomain(entry), accountCode: line.account_code, productCategoryId: line.product?.category_id, companyId: entry.company_id }); const validation = await this.distributionService.validateDistribution( line.analytic_distribution, mandatoryPlans ); if (!validation.valid) { throw new ValidationError( `Línea "${line.name}": ${validation.error}` ); } } } } ``` ### 6.2 Sincronización Bidireccional ```typescript class AnalyticSyncService { /** * Actualiza distribución JSON desde líneas analíticas * (cuando se editan líneas analíticas directamente) */ async syncDistributionFromLines(moveLineId: UUID): Promise { const moveLine = await this.getMoveLine(moveLineId); const analyticLines = await this.getAnalyticLines(moveLineId); if (analyticLines.length === 0) { await this.updateDistribution(moveLineId, null); return; } // Reconstruir distribución JSON const distribution: AnalyticDistribution = {}; for (const line of analyticLines) { // Obtener key combinando cuentas de todos los planes const accountIds = this.extractAccountIds(line); const key = accountIds.join(','); // Calcular porcentaje const percentage = moveLine.balance !== 0 ? Math.abs(line.amount / moveLine.balance) * 100 : 100; distribution[key] = Math.round(percentage * 100) / 100; // 2 decimales } await this.updateDistribution(moveLineId, distribution); } /** * Extrae IDs de cuentas de una línea analítica */ private extractAccountIds(line: AnalyticLine): UUID[] { const ids: UUID[] = []; // Columna principal (Proyectos) if (line.account_id) { ids.push(line.account_id); } // Columnas dinámicas de otros planes for (const [column, value] of Object.entries(line)) { if (column.startsWith('plan_') && column.endsWith('_account_id') && value) { ids.push(value as UUID); } } return ids.sort(); // Orden consistente } } ``` --- ## 7. API REST ### 7.1 Endpoints de Planes ``` GET /api/v1/analytics/plans # Listar planes POST /api/v1/analytics/plans # Crear plan GET /api/v1/analytics/plans/:id # Obtener plan PUT /api/v1/analytics/plans/:id # Actualizar plan DELETE /api/v1/analytics/plans/:id # Desactivar plan GET /api/v1/analytics/plans/:id/accounts # Cuentas del plan GET /api/v1/analytics/plans/relevant # Planes relevantes para contexto ``` #### GET /api/v1/analytics/plans/relevant **Query Params:** | Param | Tipo | Descripción | |-------|------|-------------| | `businessDomain` | string | invoice, bill, general, expense | | `accountCode` | string | Código de cuenta contable | | `productCategoryId` | uuid | Categoría de producto | **Response:** ```json { "plans": [ { "id": "uuid", "name": "Proyectos", "applicability": "mandatory", "accounts": [ { "id": "uuid", "name": "Torre A", "code": "PROJ-001" }, { "id": "uuid", "name": "Torre B", "code": "PROJ-002" } ] }, { "id": "uuid", "name": "Departamentos", "applicability": "optional", "accounts": [ { "id": "uuid", "name": "Construcción", "code": "DEPT-CONST" }, { "id": "uuid", "name": "Administración", "code": "DEPT-ADMIN" } ] } ] } ``` ### 7.2 Endpoints de Cuentas Analíticas ``` GET /api/v1/analytics/accounts # Listar cuentas POST /api/v1/analytics/accounts # Crear cuenta GET /api/v1/analytics/accounts/:id # Obtener cuenta con saldos PUT /api/v1/analytics/accounts/:id # Actualizar cuenta DELETE /api/v1/analytics/accounts/:id # Desactivar cuenta GET /api/v1/analytics/accounts/:id/lines # Líneas de la cuenta GET /api/v1/analytics/accounts/:id/balance # Saldo con filtros ``` #### GET /api/v1/analytics/accounts/:id/balance **Query Params:** | Param | Tipo | Descripción | |-------|------|-------------| | `dateFrom` | date | Fecha inicio | | `dateTo` | date | Fecha fin | | `groupBy` | string | plan, month, partner | **Response:** ```json { "accountId": "uuid", "accountName": "Torre A", "dateRange": { "from": "2025-01-01", "to": "2025-12-31" }, "totals": { "debit": 1500000.00, "credit": 500000.00, "balance": 1000000.00 }, "breakdown": [ { "groupKey": "2025-01", "debit": 200000.00, "credit": 50000.00, "balance": 150000.00 } ] } ``` ### 7.3 Endpoints de Distribución ``` POST /api/v1/analytics/distribution/validate # Validar distribución POST /api/v1/analytics/distribution/suggest # Sugerir distribución GET /api/v1/analytics/distribution/models # Listar modelos POST /api/v1/analytics/distribution/models # Crear modelo ``` #### POST /api/v1/analytics/distribution/validate **Request:** ```json { "distribution": { "proj-a-uuid,dept-x-uuid": 60, "proj-b-uuid,dept-x-uuid": 40 }, "context": { "businessDomain": "bill", "accountCode": "6101", "companyId": "uuid" } } ``` **Response:** ```json { "valid": true, "planSummary": [ { "planId": "uuid", "planName": "Proyectos", "total": 100, "required": 100, "status": "ok" }, { "planId": "uuid", "planName": "Departamentos", "total": 100, "required": null, "status": "ok" } ] } ``` --- ## 8. Reportes Analíticos ### 8.1 Reporte P&L por Cuenta Analítica ```typescript interface AnalyticPLReportParams { accountIds: UUID[]; // Cuentas analíticas a incluir planId?: UUID; // Filtrar por plan dateFrom: Date; dateTo: Date; companyId: UUID; comparison?: 'previous_period' | 'previous_year'; } async function generateAnalyticPLReport(params: AnalyticPLReportParams): Promise { const query = ` WITH analytic_totals AS ( SELECT al.account_id, aa.name AS account_name, ap.name AS plan_name, ga.account_type, SUM(CASE WHEN ga.account_type LIKE 'income%' THEN -al.amount ELSE 0 END) AS income, SUM(CASE WHEN ga.account_type LIKE 'expense%' THEN al.amount ELSE 0 END) AS expense FROM analytics.lines al JOIN analytics.accounts aa ON al.account_id = aa.id JOIN analytics.plans ap ON aa.plan_id = ap.id JOIN accounting.accounts ga ON al.general_account_id = ga.id WHERE al.date >= $1 AND al.date <= $2 AND al.company_id = $3 AND ($4::uuid[] IS NULL OR al.account_id = ANY($4)) AND ($5::uuid IS NULL OR ap.root_id = $5 OR ap.id = $5) GROUP BY al.account_id, aa.name, ap.name, ga.account_type ) SELECT account_id, account_name, plan_name, SUM(income) AS total_income, SUM(expense) AS total_expense, SUM(income) - SUM(expense) AS profit FROM analytic_totals GROUP BY account_id, account_name, plan_name ORDER BY plan_name, account_name `; return db.query(query, [ params.dateFrom, params.dateTo, params.companyId, params.accountIds.length > 0 ? params.accountIds : null, params.planId ]); } ``` ### 8.2 Reporte Multi-dimensional ```typescript interface MultiDimensionalReportParams { dimensions: UUID[]; // IDs de planes a incluir como dimensiones dateFrom: Date; dateTo: Date; companyId: UUID; metric: 'amount' | 'count'; } async function generateMultiDimensionalReport( params: MultiDimensionalReportParams ): Promise { // Construir columnas dinámicas basadas en planes const plans = await getPlans(params.dimensions); const columnSelects = plans.map(p => { const col = getPlanColumnName(p); return `al.${col} AS ${p.code}_account_id, aa_${p.code}.name AS ${p.code}_name`; }); const columnJoins = plans.map(p => { const col = getPlanColumnName(p); return `LEFT JOIN analytics.accounts aa_${p.code} ON al.${col} = aa_${p.code}.id`; }); const groupByCols = plans.map(p => `al.${getPlanColumnName(p)}, aa_${p.code}.name`); const query = ` SELECT ${columnSelects.join(',\n ')}, SUM(al.amount) AS total_amount, COUNT(*) AS line_count FROM analytics.lines al ${columnJoins.join('\n ')} WHERE al.date >= $1 AND al.date <= $2 AND al.company_id = $3 GROUP BY ${groupByCols.join(', ')} ORDER BY ${groupByCols.join(', ')} `; return db.query(query, [params.dateFrom, params.dateTo, params.companyId]); } ``` --- ## 9. Configuración Inicial ### 9.1 Planes Estándar para Construcción ```sql -- Crear planes base INSERT INTO analytics.plans (id, name, code, default_applicability, sequence, tenant_id) VALUES ('plan-projects', 'Proyectos', 'PROJ', 'mandatory', 1, 'tenant-id'), ('plan-departments', 'Departamentos', 'DEPT', 'optional', 2, 'tenant-id'), ('plan-cost-categories', 'Categorías de Costo', 'CAT', 'optional', 3, 'tenant-id'); -- Crear cuentas ejemplo INSERT INTO analytics.accounts (id, name, code, plan_id, tenant_id) VALUES -- Proyectos ('proj-torre-a', 'Torre A - Residencial', 'PROJ-001', 'plan-projects', 'tenant-id'), ('proj-torre-b', 'Torre B - Comercial', 'PROJ-002', 'plan-projects', 'tenant-id'), -- Departamentos ('dept-construction', 'Construcción', 'DEPT-CONST', 'plan-departments', 'tenant-id'), ('dept-admin', 'Administración', 'DEPT-ADMIN', 'plan-departments', 'tenant-id'), -- Categorías ('cat-materials', 'Materiales', 'CAT-MAT', 'plan-cost-categories', 'tenant-id'), ('cat-labor', 'Mano de Obra', 'CAT-LAB', 'plan-cost-categories', 'tenant-id'), ('cat-equipment', 'Equipo', 'CAT-EQU', 'plan-cost-categories', 'tenant-id'); -- Configurar aplicabilidad INSERT INTO analytics.plan_applicability (plan_id, business_domain, applicability, account_prefix, tenant_id) VALUES -- Proyectos obligatorio para gastos (cuentas 5xxx, 6xxx) ('plan-projects', 'bill', 'mandatory', '5,6', 'tenant-id'), ('plan-projects', 'expense', 'mandatory', NULL, 'tenant-id'), -- Departamentos opcional ('plan-departments', 'bill', 'optional', NULL, 'tenant-id'); ``` ### 9.2 Modelo de Distribución Automática ```sql -- Auto-distribuir compras de materiales al departamento de construcción INSERT INTO analytics.distribution_models ( analytic_distribution, product_category_id, account_prefix, sequence, tenant_id ) VALUES ( '{"dept-construction": 100}', 'cat-materiales-construccion', '5', 10, 'tenant-id' ); -- Auto-distribuir gastos administrativos INSERT INTO analytics.distribution_models ( analytic_distribution, account_prefix, sequence, tenant_id ) VALUES ( '{"dept-admin": 100}', '61', 20, 'tenant-id' ); ``` --- ## 10. Consideraciones de Performance ### 10.1 Índices Recomendados ```sql -- Índice compuesto para reportes por cuenta y fecha CREATE INDEX idx_analytic_lines_account_date_amount ON analytics.lines (account_id, date) INCLUDE (amount); -- Índice GIN para búsqueda en distribución JSON CREATE INDEX idx_distribution_accounts ON accounting.journal_entry_lines USING GIN (( SELECT array_agg(elem::uuid) FROM jsonb_object_keys(analytic_distribution) AS elem )); -- Índices parciales para columnas de planes opcionales CREATE INDEX idx_lines_plan2_not_null ON analytics.lines (plan_2_account_id) WHERE plan_2_account_id IS NOT NULL; ``` ### 10.2 Materialización de Saldos ```sql -- Vista materializada para saldos por cuenta CREATE MATERIALIZED VIEW analytics.account_balances AS SELECT account_id, date_trunc('month', date) AS month, SUM(amount) AS balance, SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS debit, SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END) AS credit, COUNT(*) AS line_count FROM analytics.lines GROUP BY account_id, date_trunc('month', date); CREATE UNIQUE INDEX idx_account_balances_pk ON analytics.account_balances (account_id, month); -- Refresh periódico REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.account_balances; ``` --- ## 11. Testing ### 11.1 Casos de Prueba 1. **Distribución simple**: 100% a una cuenta 2. **Distribución dividida**: 60/40 entre dos cuentas del mismo plan 3. **Multi-dimensional**: 60/40 proyectos + 100% departamento 4. **Validación**: Plan mandatory sin 100% debe fallar 5. **Modelos automáticos**: Distribución se aplica correctamente 6. **Redondeo**: Montos suman exactamente el total 7. **Sincronización**: Editar líneas actualiza distribución JSON --- ## 12. Referencias - **Odoo Source**: `addons/analytic/models/analytic_plan.py` - **Odoo Source**: `addons/analytic/models/analytic_mixin.py` - **NIF C-4**: Inventarios (para costeo por proyecto) - **Construcción**: Catálogo de conceptos INFONAVIT --- ## Historial de Cambios | Versión | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-08 | Requirements-Analyst | Versión inicial |