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