-- ===================================================== -- SCHEMA: analytics -- PROPÓSITO: Contabilidad analítica, tracking de costos/ingresos -- MÓDULOS: MGN-008 (Contabilidad Analítica) -- FECHA: 2025-11-24 -- ===================================================== -- Crear schema CREATE SCHEMA IF NOT EXISTS analytics; -- ===================================================== -- TYPES (ENUMs) -- ===================================================== CREATE TYPE analytics.account_type AS ENUM ( 'project', 'department', 'cost_center', 'customer', 'product', 'other' ); CREATE TYPE analytics.line_type AS ENUM ( 'expense', 'income', 'timesheet' ); CREATE TYPE analytics.account_status AS ENUM ( 'active', 'inactive', 'closed' ); -- ===================================================== -- TABLES -- ===================================================== -- Tabla: analytic_plans (Planes analíticos - multi-dimensional) CREATE TABLE analytics.analytic_plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID REFERENCES auth.companies(id), name VARCHAR(255) NOT NULL, description TEXT, -- Control active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_analytic_plans_name_tenant UNIQUE (tenant_id, name) ); -- Tabla: analytic_accounts (Cuentas analíticas) CREATE TABLE analytics.analytic_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, plan_id UUID REFERENCES analytics.analytic_plans(id), -- Identificación name VARCHAR(255) NOT NULL, code VARCHAR(50), account_type analytics.account_type NOT NULL DEFAULT 'other', -- Jerarquía parent_id UUID REFERENCES analytics.analytic_accounts(id), full_path TEXT, -- Generado automáticamente -- Referencias partner_id UUID REFERENCES core.partners(id), -- Cliente/proveedor asociado -- Presupuesto budget DECIMAL(15, 2) DEFAULT 0, -- Estado status analytics.account_status NOT NULL DEFAULT 'active', -- Fechas date_start DATE, date_end DATE, -- Notas description TEXT, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), deleted_at TIMESTAMP, deleted_by UUID REFERENCES auth.users(id), CONSTRAINT uq_analytic_accounts_code_company UNIQUE (company_id, code), CONSTRAINT chk_analytic_accounts_no_self_parent CHECK (id != parent_id), CONSTRAINT chk_analytic_accounts_budget CHECK (budget >= 0), CONSTRAINT chk_analytic_accounts_dates CHECK (date_end IS NULL OR date_end >= date_start) ); -- Tabla: analytic_tags (Etiquetas analíticas - clasificación cross-cutting) CREATE TABLE analytics.analytic_tags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, color VARCHAR(20), -- Color hex description TEXT, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), CONSTRAINT uq_analytic_tags_name_tenant UNIQUE (tenant_id, name) ); -- Tabla: cost_centers (Centros de costo) CREATE TABLE analytics.cost_centers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, code VARCHAR(50), analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id), -- Responsable manager_id UUID REFERENCES auth.users(id), -- Presupuesto budget_monthly DECIMAL(15, 2) DEFAULT 0, budget_annual DECIMAL(15, 2) DEFAULT 0, -- Control active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_cost_centers_code_company UNIQUE (company_id, code) ); -- Tabla: analytic_lines (Líneas analíticas - registro de costos/ingresos) CREATE TABLE analytics.analytic_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id), -- Fecha date DATE NOT NULL, -- Montos amount DECIMAL(15, 2) NOT NULL, -- Negativo=costo, Positivo=ingreso unit_amount DECIMAL(12, 4) DEFAULT 0, -- Horas para timesheet, cantidades para productos -- Tipo line_type analytics.line_type NOT NULL, -- Referencias product_id UUID REFERENCES inventory.products(id), employee_id UUID, -- FK a hr.employees (se crea después) partner_id UUID REFERENCES core.partners(id), -- Descripción name VARCHAR(255), description TEXT, -- Documento origen (polimórfico) source_model VARCHAR(100), -- 'Invoice', 'PurchaseOrder', 'SaleOrder', 'Timesheet', etc. source_id UUID, source_document VARCHAR(255), -- "invoice/123", "purchase_order/456" -- Moneda currency_id UUID REFERENCES core.currencies(id), -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), CONSTRAINT chk_analytic_lines_unit_amount CHECK (unit_amount >= 0) ); -- Tabla: analytic_line_tags (Many-to-many: líneas analíticas - tags) CREATE TABLE analytics.analytic_line_tags ( analytic_line_id UUID NOT NULL REFERENCES analytics.analytic_lines(id) ON DELETE CASCADE, analytic_tag_id UUID NOT NULL REFERENCES analytics.analytic_tags(id) ON DELETE CASCADE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (analytic_line_id, analytic_tag_id) ); -- Tabla: analytic_distributions (Distribución analítica multi-cuenta) CREATE TABLE analytics.analytic_distributions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Línea origen (polimórfico) source_model VARCHAR(100) NOT NULL, -- 'PurchaseOrderLine', 'InvoiceLine', etc. source_id UUID NOT NULL, -- Cuenta analítica destino analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id), -- Distribución percentage DECIMAL(5, 2) NOT NULL, -- 0-100 amount DECIMAL(15, 2), -- Calculado automáticamente -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_analytic_distributions_percentage CHECK (percentage >= 0 AND percentage <= 100) ); -- ===================================================== -- INDICES -- ===================================================== -- Analytic Plans CREATE INDEX idx_analytic_plans_tenant_id ON analytics.analytic_plans(tenant_id); CREATE INDEX idx_analytic_plans_active ON analytics.analytic_plans(active) WHERE active = TRUE; -- Analytic Accounts CREATE INDEX idx_analytic_accounts_tenant_id ON analytics.analytic_accounts(tenant_id); CREATE INDEX idx_analytic_accounts_company_id ON analytics.analytic_accounts(company_id); CREATE INDEX idx_analytic_accounts_plan_id ON analytics.analytic_accounts(plan_id); CREATE INDEX idx_analytic_accounts_parent_id ON analytics.analytic_accounts(parent_id); CREATE INDEX idx_analytic_accounts_partner_id ON analytics.analytic_accounts(partner_id); CREATE INDEX idx_analytic_accounts_code ON analytics.analytic_accounts(code); CREATE INDEX idx_analytic_accounts_type ON analytics.analytic_accounts(account_type); CREATE INDEX idx_analytic_accounts_status ON analytics.analytic_accounts(status); -- Analytic Tags CREATE INDEX idx_analytic_tags_tenant_id ON analytics.analytic_tags(tenant_id); CREATE INDEX idx_analytic_tags_name ON analytics.analytic_tags(name); -- Cost Centers CREATE INDEX idx_cost_centers_tenant_id ON analytics.cost_centers(tenant_id); CREATE INDEX idx_cost_centers_company_id ON analytics.cost_centers(company_id); CREATE INDEX idx_cost_centers_analytic_account_id ON analytics.cost_centers(analytic_account_id); CREATE INDEX idx_cost_centers_manager_id ON analytics.cost_centers(manager_id); CREATE INDEX idx_cost_centers_active ON analytics.cost_centers(active) WHERE active = TRUE; -- Analytic Lines CREATE INDEX idx_analytic_lines_tenant_id ON analytics.analytic_lines(tenant_id); CREATE INDEX idx_analytic_lines_company_id ON analytics.analytic_lines(company_id); CREATE INDEX idx_analytic_lines_analytic_account_id ON analytics.analytic_lines(analytic_account_id); CREATE INDEX idx_analytic_lines_date ON analytics.analytic_lines(date); CREATE INDEX idx_analytic_lines_line_type ON analytics.analytic_lines(line_type); CREATE INDEX idx_analytic_lines_product_id ON analytics.analytic_lines(product_id); CREATE INDEX idx_analytic_lines_employee_id ON analytics.analytic_lines(employee_id); CREATE INDEX idx_analytic_lines_source ON analytics.analytic_lines(source_model, source_id); -- Analytic Line Tags CREATE INDEX idx_analytic_line_tags_line_id ON analytics.analytic_line_tags(analytic_line_id); CREATE INDEX idx_analytic_line_tags_tag_id ON analytics.analytic_line_tags(analytic_tag_id); -- Analytic Distributions CREATE INDEX idx_analytic_distributions_source ON analytics.analytic_distributions(source_model, source_id); CREATE INDEX idx_analytic_distributions_analytic_account_id ON analytics.analytic_distributions(analytic_account_id); -- ===================================================== -- FUNCTIONS -- ===================================================== -- Función: update_analytic_account_path CREATE OR REPLACE FUNCTION analytics.update_analytic_account_path() RETURNS TRIGGER AS $$ DECLARE v_parent_path TEXT; BEGIN IF NEW.parent_id IS NULL THEN NEW.full_path := NEW.name; ELSE SELECT full_path INTO v_parent_path FROM analytics.analytic_accounts WHERE id = NEW.parent_id; NEW.full_path := v_parent_path || ' / ' || NEW.name; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION analytics.update_analytic_account_path IS 'Actualiza el path completo de la cuenta analítica'; -- Función: get_analytic_balance CREATE OR REPLACE FUNCTION analytics.get_analytic_balance( p_analytic_account_id UUID, p_date_from DATE DEFAULT NULL, p_date_to DATE DEFAULT NULL ) RETURNS TABLE( total_income DECIMAL, total_expense DECIMAL, balance DECIMAL ) AS $$ BEGIN RETURN QUERY SELECT COALESCE(SUM(CASE WHEN line_type = 'income' THEN amount ELSE 0 END), 0) AS total_income, COALESCE(SUM(CASE WHEN line_type = 'expense' THEN ABS(amount) ELSE 0 END), 0) AS total_expense, COALESCE(SUM(amount), 0) AS balance FROM analytics.analytic_lines WHERE analytic_account_id = p_analytic_account_id AND (p_date_from IS NULL OR date >= p_date_from) AND (p_date_to IS NULL OR date <= p_date_to); END; $$ LANGUAGE plpgsql STABLE; COMMENT ON FUNCTION analytics.get_analytic_balance IS 'Obtiene el balance de una cuenta analítica en un período'; -- Función: validate_distribution_100_percent CREATE OR REPLACE FUNCTION analytics.validate_distribution_100_percent() RETURNS TRIGGER AS $$ DECLARE v_total_percentage DECIMAL; BEGIN SELECT COALESCE(SUM(percentage), 0) INTO v_total_percentage FROM analytics.analytic_distributions WHERE source_model = NEW.source_model AND source_id = NEW.source_id; IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN v_total_percentage := v_total_percentage + NEW.percentage; END IF; IF v_total_percentage > 100 THEN RAISE EXCEPTION 'Total distribution percentage cannot exceed 100%% (currently: %%)', v_total_percentage; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION analytics.validate_distribution_100_percent IS 'Valida que la distribución analítica no exceda el 100%'; -- Función: create_analytic_line_from_invoice CREATE OR REPLACE FUNCTION analytics.create_analytic_line_from_invoice(p_invoice_line_id UUID) RETURNS UUID AS $$ DECLARE v_line RECORD; v_invoice RECORD; v_analytic_line_id UUID; v_amount DECIMAL; BEGIN -- Obtener datos de la línea de factura SELECT il.*, i.invoice_type, i.company_id, i.tenant_id, i.partner_id, i.invoice_date INTO v_line FROM financial.invoice_lines il JOIN financial.invoices i ON il.invoice_id = i.id WHERE il.id = p_invoice_line_id; IF NOT FOUND OR v_line.analytic_account_id IS NULL THEN RETURN NULL; -- Sin cuenta analítica, no crear línea END IF; -- Determinar monto (negativo para compras, positivo para ventas) IF v_line.invoice_type = 'supplier' THEN v_amount := -ABS(v_line.amount_total); ELSE v_amount := v_line.amount_total; END IF; -- Crear línea analítica INSERT INTO analytics.analytic_lines ( tenant_id, company_id, analytic_account_id, date, amount, unit_amount, line_type, product_id, partner_id, name, description, source_model, source_id, source_document ) VALUES ( v_line.tenant_id, v_line.company_id, v_line.analytic_account_id, v_line.invoice_date, v_amount, v_line.quantity, CASE WHEN v_line.invoice_type = 'supplier' THEN 'expense'::analytics.line_type ELSE 'income'::analytics.line_type END, v_line.product_id, v_line.partner_id, v_line.description, v_line.description, 'InvoiceLine', v_line.id, 'invoice_line/' || v_line.id::TEXT ) RETURNING id INTO v_analytic_line_id; RETURN v_analytic_line_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION analytics.create_analytic_line_from_invoice IS 'Crea una línea analítica a partir de una línea de factura'; -- ===================================================== -- TRIGGERS -- ===================================================== CREATE TRIGGER trg_analytic_plans_updated_at BEFORE UPDATE ON analytics.analytic_plans FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_analytic_accounts_updated_at BEFORE UPDATE ON analytics.analytic_accounts FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_cost_centers_updated_at BEFORE UPDATE ON analytics.cost_centers FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); -- Trigger: Actualizar full_path de cuenta analítica CREATE TRIGGER trg_analytic_accounts_update_path BEFORE INSERT OR UPDATE OF name, parent_id ON analytics.analytic_accounts FOR EACH ROW EXECUTE FUNCTION analytics.update_analytic_account_path(); -- Trigger: Validar distribución 100% CREATE TRIGGER trg_analytic_distributions_validate_100 BEFORE INSERT OR UPDATE ON analytics.analytic_distributions FOR EACH ROW EXECUTE FUNCTION analytics.validate_distribution_100_percent(); -- ===================================================== -- ROW LEVEL SECURITY (RLS) -- ===================================================== ALTER TABLE analytics.analytic_plans ENABLE ROW LEVEL SECURITY; ALTER TABLE analytics.analytic_accounts ENABLE ROW LEVEL SECURITY; ALTER TABLE analytics.analytic_tags ENABLE ROW LEVEL SECURITY; ALTER TABLE analytics.cost_centers ENABLE ROW LEVEL SECURITY; ALTER TABLE analytics.analytic_lines ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_analytic_plans ON analytics.analytic_plans USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_analytic_accounts ON analytics.analytic_accounts USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_analytic_tags ON analytics.analytic_tags USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_cost_centers ON analytics.cost_centers USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_analytic_lines ON analytics.analytic_lines USING (tenant_id = get_current_tenant_id()); -- ===================================================== -- COMENTARIOS -- ===================================================== COMMENT ON SCHEMA analytics IS 'Schema de contabilidad analítica y tracking de costos/ingresos'; COMMENT ON TABLE analytics.analytic_plans IS 'Planes analíticos para análisis multi-dimensional'; COMMENT ON TABLE analytics.analytic_accounts IS 'Cuentas analíticas (proyectos, departamentos, centros de costo)'; COMMENT ON TABLE analytics.analytic_tags IS 'Etiquetas analíticas para clasificación cross-cutting'; COMMENT ON TABLE analytics.cost_centers IS 'Centros de costo con presupuestos'; COMMENT ON TABLE analytics.analytic_lines IS 'Líneas analíticas de costos e ingresos'; COMMENT ON TABLE analytics.analytic_line_tags IS 'Relación many-to-many entre líneas y tags'; COMMENT ON TABLE analytics.analytic_distributions IS 'Distribución de montos a múltiples cuentas analíticas'; -- ===================================================== -- VISTAS ÚTILES -- ===================================================== -- Vista: balance analítico por cuenta CREATE OR REPLACE VIEW analytics.analytic_balance_view AS SELECT aa.id AS analytic_account_id, aa.code, aa.name, aa.budget, COALESCE(SUM(CASE WHEN al.line_type = 'income' THEN al.amount ELSE 0 END), 0) AS total_income, COALESCE(SUM(CASE WHEN al.line_type = 'expense' THEN ABS(al.amount) ELSE 0 END), 0) AS total_expense, COALESCE(SUM(al.amount), 0) AS balance, aa.budget - COALESCE(SUM(CASE WHEN al.line_type = 'expense' THEN ABS(al.amount) ELSE 0 END), 0) AS budget_variance FROM analytics.analytic_accounts aa LEFT JOIN analytics.analytic_lines al ON aa.id = al.analytic_account_id WHERE aa.deleted_at IS NULL GROUP BY aa.id, aa.code, aa.name, aa.budget; COMMENT ON VIEW analytics.analytic_balance_view IS 'Vista de balance analítico por cuenta con presupuesto vs real'; -- ===================================================== -- FIN DEL SCHEMA ANALYTICS -- =====================================================