erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-PLANTILLAS-CUENTAS.md

62 KiB

SPEC-PLANTILLAS-CUENTAS: Sistema de Plantillas de Plan de Cuentas por País

Metadata

  • Código: SPEC-PLANTILLAS-CUENTAS
  • Versión: 1.0.0
  • Fecha: 2025-01-15
  • Gap Relacionado: GAP-MGN-004-003
  • Módulo: MGN-004 (Contabilidad)
  • Prioridad: P1
  • Story Points: 8
  • Odoo Referencia: account (chart_template), l10n_mx, l10n_es, l10n_co

1. Resumen Ejecutivo

1.1 Descripción del Gap

El sistema contable actual tiene un plan de cuentas genérico. Se requiere un sistema de plantillas de plan de cuentas por país que permita instalar automáticamente estructuras contables completas (cuentas, grupos, impuestos, diarios) adaptadas a normativas locales como México (SAT), España (PGC), o Colombia.

1.2 Impacto en el Negocio

Aspecto Sistema Actual Con Plantillas por País
Configuración inicial Manual (días) Automática (minutos)
Cumplimiento local Requiere experto Incluido en plantilla
Cuentas SAT México No estructuradas Catálogo completo
Impuestos locales Configuración manual Predefinidos
Multi-país No soportado Por empresa/subsidiaria
Actualizaciones Manuales Recarga de plantilla

1.3 Objetivos de la Especificación

  1. Diseñar sistema de plantillas de plan de cuentas
  2. Soportar jerarquía de plantillas (herencia)
  3. Implementar carga automática al crear empresa
  4. Incluir plantillas para México, España, Colombia
  5. Permitir recarga/actualización de plantillas
  6. Integrar con sistema de impuestos y diarios

2. Arquitectura de Datos

2.1 Diagrama del Sistema

┌─────────────────────────────────────────────────────────────────────────────┐
│                    SISTEMA DE PLANTILLAS DE CUENTAS                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                    REGISTRO DE PLANTILLAS                           │    │
│  │                                                                     │    │
│  │   generic_coa (base)                                                │    │
│  │        │                                                            │    │
│  │        ├─── mx (México - SAT)                                       │    │
│  │        │                                                            │    │
│  │        ├─── es_common (España base)                                 │    │
│  │        │       ├─── es_pymes (PYMES)                                │    │
│  │        │       └─── es_full (Completo)                              │    │
│  │        │                                                            │    │
│  │        └─── co (Colombia - PUC)                                     │    │
│  │                                                                     │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                    │                                        │
│                                    ▼                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                    CONTENIDO DE PLANTILLA                           │    │
│  │                                                                     │    │
│  │   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                │    │
│  │   │  Cuentas    │  │   Grupos    │  │  Impuestos  │                │    │
│  │   │  contables  │  │  jerárquicos│  │  locales    │                │    │
│  │   └─────────────┘  └─────────────┘  └─────────────┘                │    │
│  │                                                                     │    │
│  │   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐                │    │
│  │   │   Diarios   │  │  Posiciones │  │ Propiedades │                │    │
│  │   │  estándar   │  │  fiscales   │  │  empresa    │                │    │
│  │   └─────────────┘  └─────────────┘  └─────────────┘                │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                    │                                        │
│                                    ▼                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                    PROCESO DE INSTALACIÓN                           │    │
│  │                                                                     │    │
│  │   Seleccionar   →   Cargar      →    Crear      →    Configurar    │    │
│  │   plantilla         datos CSV        registros       empresa       │    │
│  │                                                                     │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

2.2 Definición de Tablas

-- =============================================================================
-- SCHEMA: accounting
-- =============================================================================

-- -----------------------------------------------------------------------------
-- Tabla: chart_templates
-- Descripción: Registro de plantillas de plan de cuentas disponibles
-- -----------------------------------------------------------------------------
CREATE TABLE chart_templates (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    code VARCHAR(50) NOT NULL UNIQUE,  -- 'mx', 'es_pymes', 'co', etc.

    -- Información
    name VARCHAR(200) NOT NULL,
    description TEXT,

    -- Jerarquía
    parent_code VARCHAR(50) REFERENCES chart_templates(code),

    -- País asociado
    country_id UUID REFERENCES countries(id),

    -- Configuración
    code_digits INTEGER DEFAULT 6,  -- Dígitos en códigos de cuenta
    visible BOOLEAN DEFAULT TRUE,    -- Visible en selector
    sequence INTEGER DEFAULT 10,

    -- Propiedades por defecto
    property_account_receivable_code VARCHAR(20),
    property_account_payable_code VARCHAR(20),
    property_account_income_code VARCHAR(20),
    property_account_expense_code VARCHAR(20),

    -- Stock
    property_stock_valuation_code VARCHAR(20),
    property_stock_input_code VARCHAR(20),
    property_stock_output_code VARCHAR(20),

    -- Configuración empresa
    anglo_saxon_accounting BOOLEAN DEFAULT TRUE,
    tax_calculation_rounding VARCHAR(20) DEFAULT 'round_globally',

    -- Prefijos
    bank_account_code_prefix VARCHAR(20),
    cash_account_code_prefix VARCHAR(20),
    transfer_account_code_prefix VARCHAR(20),

    -- Impuestos por defecto
    default_sale_tax_code VARCHAR(50),
    default_purchase_tax_code VARCHAR(50),

    -- Auditoría
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_chart_templates_country ON chart_templates(country_id);
CREATE INDEX idx_chart_templates_parent ON chart_templates(parent_code);

-- -----------------------------------------------------------------------------
-- Tabla: account_groups
-- Descripción: Grupos jerárquicos de cuentas (rangos de códigos)
-- -----------------------------------------------------------------------------
CREATE TABLE account_groups (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),

    -- Datos principales
    name VARCHAR(200) NOT NULL,
    code_prefix_start VARCHAR(20),  -- '100' - inicio del rango
    code_prefix_end VARCHAR(20),    -- '199' - fin del rango (opcional)

    -- Jerarquía
    parent_id UUID REFERENCES account_groups(id),

    -- Auditoría
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    created_by UUID REFERENCES users(id),
    updated_by UUID REFERENCES users(id)
);

CREATE INDEX idx_account_groups_tenant ON account_groups(tenant_id);
CREATE INDEX idx_account_groups_parent ON account_groups(parent_id);
CREATE INDEX idx_account_groups_prefix ON account_groups(code_prefix_start);

-- RLS
ALTER TABLE account_groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY account_groups_tenant_isolation ON account_groups
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- -----------------------------------------------------------------------------
-- Tabla: accounts
-- Descripción: Plan de cuentas contable
-- -----------------------------------------------------------------------------
CREATE TABLE accounts (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),

    -- Datos principales
    code VARCHAR(64) NOT NULL,
    name VARCHAR(200) NOT NULL,

    -- Clasificación
    account_type account_type NOT NULL,

    -- Grupo jerárquico
    group_id UUID REFERENCES account_groups(id),

    -- Configuración
    reconcile BOOLEAN DEFAULT FALSE,
    deprecated BOOLEAN DEFAULT FALSE,
    non_trade BOOLEAN DEFAULT FALSE,  -- Para receivable/payable no comerciales

    -- Moneda (NULL = multi-moneda)
    currency_id UUID REFERENCES currencies(id),

    -- Impuestos por defecto
    tax_ids UUID[],

    -- Tags para reportes
    tag_ids UUID[],

    -- Origen de plantilla
    template_code VARCHAR(50),  -- Código de plantilla origen

    -- Auditoría
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    created_by UUID REFERENCES users(id),
    updated_by UUID REFERENCES users(id),

    -- Constraints
    CONSTRAINT uq_accounts_code UNIQUE(tenant_id, code)
);

-- Tipo de cuenta
CREATE TYPE account_type AS ENUM (
    -- Activos
    'asset_receivable',
    'asset_cash',
    'asset_current',
    'asset_non_current',
    'asset_prepayments',
    'asset_fixed',
    -- Pasivos
    'liability_payable',
    'liability_credit_card',
    'liability_current',
    'liability_non_current',
    -- Patrimonio
    'equity',
    'equity_unaffected',
    -- Resultados
    'income',
    'income_other',
    'expense',
    'expense_depreciation',
    'expense_direct_cost',
    -- Especial
    'off_balance'
);

CREATE INDEX idx_accounts_tenant ON accounts(tenant_id);
CREATE INDEX idx_accounts_code ON accounts(code);
CREATE INDEX idx_accounts_type ON accounts(account_type);
CREATE INDEX idx_accounts_group ON accounts(group_id);
CREATE INDEX idx_accounts_reconcile ON accounts(tenant_id, reconcile) WHERE reconcile = TRUE;

-- RLS
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY accounts_tenant_isolation ON accounts
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- -----------------------------------------------------------------------------
-- Tabla: journals
-- Descripción: Diarios contables
-- -----------------------------------------------------------------------------
CREATE TABLE journals (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),

    -- Datos principales
    name VARCHAR(100) NOT NULL,
    code VARCHAR(10) NOT NULL,
    type journal_type NOT NULL,

    -- Cuentas por defecto
    default_account_id UUID REFERENCES accounts(id),

    -- Para tipo banco
    bank_account_id UUID REFERENCES bank_accounts(id),

    -- Configuración
    sequence INTEGER DEFAULT 10,
    color INTEGER,
    show_on_dashboard BOOLEAN DEFAULT TRUE,

    -- Estado
    active BOOLEAN DEFAULT TRUE,

    -- Auditoría
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    created_by UUID REFERENCES users(id),
    updated_by UUID REFERENCES users(id),

    -- Constraints
    CONSTRAINT uq_journals_code UNIQUE(tenant_id, code)
);

CREATE TYPE journal_type AS ENUM (
    'sale',      -- Facturas de venta
    'purchase',  -- Facturas de compra
    'cash',      -- Caja
    'bank',      -- Banco
    'general'    -- Misceláneos
);

CREATE INDEX idx_journals_tenant ON journals(tenant_id);
CREATE INDEX idx_journals_type ON journals(type);

-- RLS
ALTER TABLE journals ENABLE ROW LEVEL SECURITY;
CREATE POLICY journals_tenant_isolation ON journals
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- -----------------------------------------------------------------------------
-- Tabla: company_chart_config
-- Descripción: Configuración de plan de cuentas por empresa
-- -----------------------------------------------------------------------------
CREATE TABLE company_chart_config (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),

    -- Plantilla instalada
    chart_template_code VARCHAR(50) REFERENCES chart_templates(code),

    -- Cuentas por defecto (propiedades)
    property_account_receivable_id UUID REFERENCES accounts(id),
    property_account_payable_id UUID REFERENCES accounts(id),
    property_account_income_categ_id UUID REFERENCES accounts(id),
    property_account_expense_categ_id UUID REFERENCES accounts(id),

    -- Stock
    property_stock_valuation_account_id UUID REFERENCES accounts(id),
    property_stock_account_input_id UUID REFERENCES accounts(id),
    property_stock_account_output_id UUID REFERENCES accounts(id),

    -- Diferencia de cambio
    income_currency_exchange_account_id UUID REFERENCES accounts(id),
    expense_currency_exchange_account_id UUID REFERENCES accounts(id),

    -- Impuestos por defecto
    account_sale_tax_id UUID REFERENCES taxes(id),
    account_purchase_tax_id UUID REFERENCES taxes(id),

    -- Diarios especiales
    currency_exchange_journal_id UUID REFERENCES journals(id),
    tax_cash_basis_journal_id UUID REFERENCES journals(id),

    -- Configuración
    anglo_saxon_accounting BOOLEAN DEFAULT TRUE,
    tax_exigibility BOOLEAN DEFAULT FALSE,
    tax_calculation_rounding_method VARCHAR(20) DEFAULT 'round_globally',

    -- Prefijos
    bank_account_code_prefix VARCHAR(20),
    cash_account_code_prefix VARCHAR(20),
    transfer_account_code_prefix VARCHAR(20),

    -- Auditoría
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),

    -- Constraints
    CONSTRAINT uq_company_chart_config UNIQUE(tenant_id)
);

-- RLS
ALTER TABLE company_chart_config ENABLE ROW LEVEL SECURITY;
CREATE POLICY company_chart_config_tenant_isolation ON company_chart_config
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- -----------------------------------------------------------------------------
-- Tabla: chart_template_data
-- Descripción: Datos CSV embebidos para plantillas (cuentas, grupos, impuestos)
-- -----------------------------------------------------------------------------
CREATE TABLE chart_template_data (
    -- Identificación
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Referencia a plantilla
    template_code VARCHAR(50) NOT NULL REFERENCES chart_templates(code),

    -- Tipo de dato
    model_name VARCHAR(100) NOT NULL,  -- 'account', 'account_group', 'tax', etc.

    -- Identificador externo (xml_id)
    external_id VARCHAR(200) NOT NULL,

    -- Datos JSON
    data JSONB NOT NULL,

    -- Orden de carga
    sequence INTEGER DEFAULT 10,

    -- Constraints
    CONSTRAINT uq_chart_template_data UNIQUE(template_code, model_name, external_id)
);

CREATE INDEX idx_chart_template_data_template ON chart_template_data(template_code);
CREATE INDEX idx_chart_template_data_model ON chart_template_data(model_name);

3. Lógica de Negocio

3.1 Cargador de Plantillas

# services/chart_template_loader.py
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
import json

@dataclass
class LoadResult:
    success: bool
    accounts_created: int
    groups_created: int
    taxes_created: int
    journals_created: int
    errors: List[str]

class ChartTemplateLoader:
    """Carga plantillas de plan de cuentas en una empresa."""

    # Orden de carga de modelos
    TEMPLATE_MODELS = [
        'account_group',
        'tax_group',
        'tax',
        'account',
        'journal',
        'fiscal_position',
    ]

    def __init__(self, db_session):
        self.db = db_session
        self.xml_id_cache = {}  # {external_id: real_id}

    async def load(
        self,
        template_code: str,
        tenant_id: str,
        force_reload: bool = False
    ) -> LoadResult:
        """
        Carga una plantilla de plan de cuentas.

        Args:
            template_code: Código de plantilla ('mx', 'es_pymes', etc.)
            tenant_id: ID del tenant/empresa
            force_reload: Si recargar datos existentes

        Returns:
            LoadResult con estadísticas de carga
        """
        errors = []
        stats = {model: 0 for model in self.TEMPLATE_MODELS}

        try:
            # 1. Obtener plantilla y sus padres
            templates = await self._get_template_hierarchy(template_code)

            if not templates:
                return LoadResult(
                    success=False,
                    accounts_created=0,
                    groups_created=0,
                    taxes_created=0,
                    journals_created=0,
                    errors=[f"Plantilla '{template_code}' no encontrada"]
                )

            # 2. Verificar si ya está instalada
            existing_config = await self.db.get_company_chart_config(tenant_id)

            if existing_config and not force_reload:
                if existing_config['chart_template_code'] == template_code:
                    return LoadResult(
                        success=True,
                        accounts_created=0,
                        groups_created=0,
                        taxes_created=0,
                        journals_created=0,
                        errors=["Plantilla ya instalada. Use force_reload=True para recargar."]
                    )

            # 3. Limpiar datos anteriores si es recarga
            if force_reload and existing_config:
                await self._cleanup_existing_data(tenant_id)

            # 4. Cargar datos de todas las plantillas en jerarquía
            all_data = await self._merge_template_data(templates)

            # 5. Cargar cada modelo en orden
            for model_name in self.TEMPLATE_MODELS:
                model_data = all_data.get(model_name, {})

                for external_id, values in model_data.items():
                    try:
                        real_id = await self._create_record(
                            tenant_id=tenant_id,
                            model_name=model_name,
                            external_id=external_id,
                            values=values
                        )
                        self.xml_id_cache[external_id] = real_id
                        stats[model_name] += 1

                    except Exception as e:
                        errors.append(f"Error creando {model_name}/{external_id}: {str(e)}")

            # 6. Configurar propiedades de la empresa
            main_template = templates[-1]  # La plantilla principal (última en jerarquía)
            await self._configure_company(tenant_id, main_template)

            # 7. Guardar configuración
            await self.db.upsert_company_chart_config(
                tenant_id=tenant_id,
                chart_template_code=template_code
            )

            return LoadResult(
                success=len(errors) == 0,
                accounts_created=stats.get('account', 0),
                groups_created=stats.get('account_group', 0),
                taxes_created=stats.get('tax', 0),
                journals_created=stats.get('journal', 0),
                errors=errors
            )

        except Exception as e:
            return LoadResult(
                success=False,
                accounts_created=0,
                groups_created=0,
                taxes_created=0,
                journals_created=0,
                errors=[f"Error fatal: {str(e)}"]
            )

    async def _get_template_hierarchy(
        self,
        template_code: str
    ) -> List[Dict]:
        """
        Obtiene la jerarquía de plantillas (de padre a hijo).

        Ejemplo: es_pymes -> [generic_coa, es_common, es_pymes]
        """
        hierarchy = []
        current_code = template_code

        while current_code:
            template = await self.db.get_chart_template(current_code)
            if not template:
                break

            hierarchy.insert(0, template)  # Insertar al inicio
            current_code = template.get('parent_code')

        return hierarchy

    async def _merge_template_data(
        self,
        templates: List[Dict]
    ) -> Dict[str, Dict]:
        """
        Fusiona datos de plantillas en jerarquía.

        Los hijos sobrescriben valores de padres.
        """
        merged = {}

        for template in templates:
            template_data = await self.db.get_chart_template_data(
                template['code']
            )

            for record in template_data:
                model_name = record['model_name']
                external_id = record['external_id']
                data = record['data']

                if model_name not in merged:
                    merged[model_name] = {}

                if external_id in merged[model_name]:
                    # Fusionar con datos existentes
                    merged[model_name][external_id].update(data)
                else:
                    merged[model_name][external_id] = data

        return merged

    async def _create_record(
        self,
        tenant_id: str,
        model_name: str,
        external_id: str,
        values: Dict
    ) -> str:
        """Crea un registro resolviendo referencias."""

        # Resolver referencias a otros registros
        resolved_values = await self._resolve_references(values)

        # Agregar tenant_id
        resolved_values['tenant_id'] = tenant_id
        resolved_values['template_code'] = external_id.split('.')[0] if '.' in external_id else None

        # Crear según modelo
        if model_name == 'account_group':
            return await self.db.create_account_group(resolved_values)
        elif model_name == 'account':
            return await self.db.create_account(resolved_values)
        elif model_name == 'tax_group':
            return await self.db.create_tax_group(resolved_values)
        elif model_name == 'tax':
            return await self.db.create_tax(resolved_values)
        elif model_name == 'journal':
            return await self.db.create_journal(resolved_values)
        elif model_name == 'fiscal_position':
            return await self.db.create_fiscal_position(resolved_values)
        else:
            raise ValueError(f"Modelo desconocido: {model_name}")

    async def _resolve_references(self, values: Dict) -> Dict:
        """Resuelve referencias xml_id a IDs reales."""

        resolved = {}

        for key, value in values.items():
            if isinstance(value, str) and value.startswith('ref:'):
                # Es una referencia: 'ref:mx.cuenta105_01'
                ref_id = value[4:]  # Quitar 'ref:'

                if ref_id in self.xml_id_cache:
                    resolved[key] = self.xml_id_cache[ref_id]
                else:
                    # Buscar en BD
                    real_id = await self.db.get_id_by_external_id(ref_id)
                    if real_id:
                        self.xml_id_cache[ref_id] = real_id
                        resolved[key] = real_id
                    else:
                        resolved[key] = None  # Referencia no encontrada

            elif isinstance(value, list):
                # Lista de referencias
                resolved[key] = []
                for item in value:
                    if isinstance(item, str) and item.startswith('ref:'):
                        ref_id = item[4:]
                        if ref_id in self.xml_id_cache:
                            resolved[key].append(self.xml_id_cache[ref_id])
                    else:
                        resolved[key].append(item)
            else:
                resolved[key] = value

        return resolved

    async def _configure_company(
        self,
        tenant_id: str,
        template: Dict
    ):
        """Configura propiedades de la empresa desde la plantilla."""

        config = {}

        # Mapear códigos de cuenta a IDs
        property_mappings = [
            ('property_account_receivable_code', 'property_account_receivable_id'),
            ('property_account_payable_code', 'property_account_payable_id'),
            ('property_account_income_code', 'property_account_income_categ_id'),
            ('property_account_expense_code', 'property_account_expense_categ_id'),
            ('property_stock_valuation_code', 'property_stock_valuation_account_id'),
            ('property_stock_input_code', 'property_stock_account_input_id'),
            ('property_stock_output_code', 'property_stock_account_output_id'),
        ]

        for code_field, id_field in property_mappings:
            code = template.get(code_field)
            if code:
                account = await self.db.get_account_by_code(tenant_id, code)
                if account:
                    config[id_field] = account['id']

        # Impuestos por defecto
        if template.get('default_sale_tax_code'):
            tax = await self.db.get_tax_by_code(
                tenant_id,
                template['default_sale_tax_code']
            )
            if tax:
                config['account_sale_tax_id'] = tax['id']

        if template.get('default_purchase_tax_code'):
            tax = await self.db.get_tax_by_code(
                tenant_id,
                template['default_purchase_tax_code']
            )
            if tax:
                config['account_purchase_tax_id'] = tax['id']

        # Configuración general
        config['anglo_saxon_accounting'] = template.get('anglo_saxon_accounting', True)
        config['tax_calculation_rounding_method'] = template.get(
            'tax_calculation_rounding',
            'round_globally'
        )
        config['bank_account_code_prefix'] = template.get('bank_account_code_prefix')
        config['cash_account_code_prefix'] = template.get('cash_account_code_prefix')
        config['transfer_account_code_prefix'] = template.get('transfer_account_code_prefix')

        await self.db.update_company_chart_config(tenant_id, config)

    async def _cleanup_existing_data(self, tenant_id: str):
        """Limpia datos existentes antes de recargar."""

        # Orden inverso para respetar foreign keys
        for model_name in reversed(self.TEMPLATE_MODELS):
            await self.db.delete_template_records(tenant_id, model_name)

3.2 Selector de Plantillas

# services/chart_template_selector.py
from typing import List, Dict, Optional

@dataclass
class TemplateOption:
    code: str
    name: str
    country_id: Optional[str]
    country_name: Optional[str]
    description: Optional[str]
    is_recommended: bool

class ChartTemplateSelector:
    """Selecciona plantillas disponibles para una empresa."""

    def __init__(self, db_session):
        self.db = db_session

    async def get_available_templates(
        self,
        country_id: Optional[str] = None
    ) -> List[TemplateOption]:
        """
        Obtiene plantillas disponibles, priorizando por país.

        Args:
            country_id: ID del país de la empresa (opcional)

        Returns:
            Lista de plantillas ordenadas por relevancia
        """
        templates = await self.db.get_visible_chart_templates()

        options = []
        for template in templates:
            is_recommended = False

            # Marcar como recomendada si coincide con el país
            if country_id and template['country_id'] == country_id:
                is_recommended = True

            options.append(TemplateOption(
                code=template['code'],
                name=template['name'],
                country_id=template['country_id'],
                country_name=template.get('country_name'),
                description=template.get('description'),
                is_recommended=is_recommended
            ))

        # Ordenar: recomendadas primero, luego por nombre
        options.sort(key=lambda x: (not x.is_recommended, x.name))

        return options

    async def auto_detect_template(
        self,
        country_id: str
    ) -> Optional[str]:
        """
        Detecta automáticamente la plantilla para un país.

        Args:
            country_id: ID del país

        Returns:
            Código de plantilla o None
        """
        # Buscar plantilla específica del país
        template = await self.db.get_chart_template_by_country(country_id)

        if template:
            return template['code']

        # Fallback a plantilla genérica
        return 'generic_coa'

3.3 Sincronizador de Grupos

# services/account_group_sync.py
from typing import List, Dict

class AccountGroupSync:
    """Sincroniza jerarquía de grupos basándose en códigos de cuenta."""

    def __init__(self, db_session):
        self.db = db_session

    async def sync_groups(self, tenant_id: str):
        """
        Sincroniza grupos de cuentas basándose en prefijos.

        Asigna cada cuenta al grupo más específico que coincida
        con su código.
        """
        # Obtener todos los grupos ordenados por especificidad
        groups = await self.db.get_account_groups(tenant_id)
        groups_by_prefix = self._build_prefix_index(groups)

        # Obtener todas las cuentas
        accounts = await self.db.get_accounts(tenant_id)

        # Asignar grupo a cada cuenta
        for account in accounts:
            group_id = self._find_matching_group(
                account['code'],
                groups_by_prefix
            )

            if group_id != account.get('group_id'):
                await self.db.update_account_group(
                    account['id'],
                    group_id
                )

    def _build_prefix_index(
        self,
        groups: List[Dict]
    ) -> Dict[str, str]:
        """Construye índice de prefijos a grupos."""

        index = {}

        for group in groups:
            start = group['code_prefix_start']
            end = group.get('code_prefix_end', start)

            # Generar todos los prefijos en el rango
            # Ejemplo: 100-199 genera 100, 101, ..., 199
            for prefix in self._generate_prefix_range(start, end):
                # Si ya existe, mantener el más específico (más largo)
                if prefix not in index or len(prefix) > len(index[prefix]):
                    index[prefix] = group['id']

        return index

    def _generate_prefix_range(
        self,
        start: str,
        end: str
    ) -> List[str]:
        """Genera rango de prefijos."""

        if start == end:
            return [start]

        prefixes = []

        # Convertir a números para iterar
        try:
            start_num = int(start)
            end_num = int(end)

            for num in range(start_num, end_num + 1):
                prefixes.append(str(num).zfill(len(start)))

        except ValueError:
            # Si no son números, solo incluir start
            prefixes = [start]

        return prefixes

    def _find_matching_group(
        self,
        account_code: str,
        groups_by_prefix: Dict[str, str]
    ) -> Optional[str]:
        """Encuentra el grupo más específico para una cuenta."""

        # Probar prefijos de mayor a menor longitud
        for length in range(len(account_code), 0, -1):
            prefix = account_code[:length]
            if prefix in groups_by_prefix:
                return groups_by_prefix[prefix]

        return None

4. API REST

4.1 Endpoints

# Plantillas
GET /api/v1/chart-templates:
  summary: Listar plantillas disponibles
  params:
    country_id: uuid
    visible_only: boolean (default: true)
  response: ChartTemplate[]

GET /api/v1/chart-templates/{code}:
  summary: Obtener detalle de plantilla
  response: ChartTemplateDetail

POST /api/v1/chart-templates/{code}/install:
  summary: Instalar plantilla en tenant actual
  body:
    force_reload: boolean (default: false)
  response:
    success: boolean
    accounts_created: integer
    groups_created: integer
    taxes_created: integer
    journals_created: integer
    errors: string[]

# Cuentas
GET /api/v1/accounts:
  summary: Listar cuentas
  params:
    account_type: enum
    group_id: uuid
    reconcile: boolean
    search: string
    page: integer
    limit: integer
  response: Account[]

POST /api/v1/accounts:
  summary: Crear cuenta
  body:
    code: string (required)
    name: string (required)
    account_type: enum (required)
    group_id: uuid
    reconcile: boolean
    currency_id: uuid
    tax_ids: uuid[]
  response: Account

GET /api/v1/accounts/{id}:
  summary: Obtener cuenta
  response: Account

PUT /api/v1/accounts/{id}:
  summary: Actualizar cuenta
  body: Partial<Account>
  response: Account

DELETE /api/v1/accounts/{id}:
  summary: Deprecar cuenta
  response: { success: boolean }

# Grupos de Cuentas
GET /api/v1/account-groups:
  summary: Listar grupos de cuentas
  response: AccountGroup[]

POST /api/v1/account-groups:
  summary: Crear grupo
  body:
    name: string (required)
    code_prefix_start: string (required)
    code_prefix_end: string
    parent_id: uuid
  response: AccountGroup

GET /api/v1/account-groups/tree:
  summary: Obtener árbol jerárquico de grupos
  response: AccountGroupTree

POST /api/v1/account-groups/sync:
  summary: Sincronizar asignación de cuentas a grupos
  response: { accounts_updated: integer }

# Diarios
GET /api/v1/journals:
  summary: Listar diarios
  params:
    type: enum
    active: boolean
  response: Journal[]

POST /api/v1/journals:
  summary: Crear diario
  body:
    name: string (required)
    code: string (required)
    type: enum (required)
    default_account_id: uuid
  response: Journal

# Configuración
GET /api/v1/company/chart-config:
  summary: Obtener configuración del plan de cuentas
  response: CompanyChartConfig

PUT /api/v1/company/chart-config:
  summary: Actualizar configuración
  body: Partial<CompanyChartConfig>
  response: CompanyChartConfig

4.2 Schemas

// types/chart_template.ts

interface ChartTemplate {
  code: string;
  name: string;
  description: string | null;
  parent_code: string | null;
  country_id: string | null;
  country_name: string | null;
  code_digits: number;
  visible: boolean;
  sequence: number;
}

interface ChartTemplateDetail extends ChartTemplate {
  accounts_count: number;
  groups_count: number;
  taxes_count: number;
  journals_count: number;
  property_account_receivable_code: string | null;
  property_account_payable_code: string | null;
}

interface Account {
  id: string;
  code: string;
  name: string;
  account_type: AccountType;
  group_id: string | null;
  group: AccountGroup | null;
  reconcile: boolean;
  deprecated: boolean;
  non_trade: boolean;
  currency_id: string | null;
  tax_ids: string[];
  tag_ids: string[];
  created_at: string;
  updated_at: string;
}

type AccountType =
  | 'asset_receivable'
  | 'asset_cash'
  | 'asset_current'
  | 'asset_non_current'
  | 'asset_prepayments'
  | 'asset_fixed'
  | 'liability_payable'
  | 'liability_credit_card'
  | 'liability_current'
  | 'liability_non_current'
  | 'equity'
  | 'equity_unaffected'
  | 'income'
  | 'income_other'
  | 'expense'
  | 'expense_depreciation'
  | 'expense_direct_cost'
  | 'off_balance';

interface AccountGroup {
  id: string;
  name: string;
  code_prefix_start: string;
  code_prefix_end: string | null;
  parent_id: string | null;
  children: AccountGroup[];
  accounts_count: number;
}

interface Journal {
  id: string;
  name: string;
  code: string;
  type: 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
  default_account_id: string | null;
  default_account: Account | null;
  sequence: number;
  color: number | null;
  show_on_dashboard: boolean;
  active: boolean;
}

interface CompanyChartConfig {
  chart_template_code: string | null;
  chart_template_name: string | null;
  property_account_receivable_id: string | null;
  property_account_payable_id: string | null;
  property_account_income_categ_id: string | null;
  property_account_expense_categ_id: string | null;
  property_stock_valuation_account_id: string | null;
  account_sale_tax_id: string | null;
  account_purchase_tax_id: string | null;
  anglo_saxon_accounting: boolean;
  tax_calculation_rounding_method: string;
  bank_account_code_prefix: string | null;
  cash_account_code_prefix: string | null;
}

5. Interfaz de Usuario

5.1 Selector de Plantilla (Onboarding)

┌─────────────────────────────────────────────────────────────────────────────┐
│                    Configurar Plan de Cuentas                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  Selecciona el plan de cuentas que mejor se adapte a tu país y tipo         │
│  de empresa. Puedes modificar las cuentas después de la instalación.        │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │ ★ RECOMENDADO                                                       │   │
│  │                                                                      │   │
│  │ 🇲🇽 México - Plan de Cuentas SAT                                     │   │
│  │    Catálogo de cuentas conforme al código agrupador del SAT.        │   │
│  │    Incluye IVA, ISR, IEPS y retenciones predefinidas.               │   │
│  │                                                    [Instalar →]      │   │
│  │                                                                      │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │ OTRAS OPCIONES                                                      │   │
│  ├─────────────────────────────────────────────────────────────────────┤   │
│  │                                                                      │   │
│  │ 🇪🇸 España - PYMES (PGC 2008)                         [Instalar →]   │   │
│  │    Plan General Contable simplificado para pequeñas empresas.       │   │
│  │                                                                      │   │
│  │ 🇪🇸 España - Completo (PGC 2008)                      [Instalar →]   │   │
│  │    Plan General Contable completo.                                  │   │
│  │                                                                      │   │
│  │ 🇨🇴 Colombia - PUC                                    [Instalar →]   │   │
│  │    Plan Único de Cuentas para comerciantes.                         │   │
│  │                                                                      │   │
│  │ 🌐 Plan Genérico                                      [Instalar →]   │   │
│  │    Plan de cuentas básico sin localización específica.              │   │
│  │                                                                      │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│                                                      [Omitir por ahora]     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

5.2 Plan de Cuentas

┌─────────────────────────────────────────────────────────────────────────────┐
│ Contabilidad > Configuración > Plan de Cuentas                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  [+ Nueva Cuenta]    [Filtros ▼]    [Agrupar ▼]    🔍 Buscar...            │
│                                                                             │
│  Vista: [Lista] [Árbol]         Plantilla actual: México - SAT              │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │ ▼ 1 - Activos                                                       │   │
│  ├─────────────────────────────────────────────────────────────────────┤   │
│  │   ▼ 100 - Activo a Corto Plazo                                      │   │
│  │   │                                                                  │   │
│  │   │   ▼ 101 - Caja                                                  │   │
│  │   │   │ Código   │ Nombre                    │ Tipo            │ R  │   │
│  │   │   │──────────│───────────────────────────│─────────────────│────│   │
│  │   │   │ 101.01   │ Caja y efectivo           │ Banco y Caja    │    │   │
│  │   │   │ 101.02   │ Caja chica                │ Banco y Caja    │    │   │
│  │   │   │                                                              │   │
│  │   │   ▼ 102 - Bancos                                                │   │
│  │   │   │ 102.01   │ Bancos nacionales         │ Banco y Caja    │    │   │
│  │   │   │ 102.02   │ Bancos extranjeros        │ Banco y Caja    │    │   │
│  │   │   │                                                              │   │
│  │   │   ▼ 105 - Clientes                                              │   │
│  │   │   │ 105.01   │ Clientes nacionales       │ Por Cobrar      │ ✓  │   │
│  │   │   │ 105.02   │ Clientes extranjeros      │ Por Cobrar      │ ✓  │   │
│  │   │                                                                  │   │
│  │   ▼ 110 - Activo a Largo Plazo                                      │   │
│  │   │ ...                                                              │   │
│  │                                                                      │   │
│  │ ▼ 2 - Pasivos                                                       │   │
│  │   ...                                                                │   │
│  │                                                                      │   │
│  │ ▼ 4 - Ingresos                                                      │   │
│  │   ...                                                                │   │
│  │                                                                      │   │
│  │ ▼ 6 - Gastos                                                        │   │
│  │   ...                                                                │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  R = Reconciliable                                                          │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

5.3 Formulario de Cuenta

┌─────────────────────────────────────────────────────────────────────────────┐
│ Cuenta: 105.01 - Clientes nacionales                        [Guardar] [✕]   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │ Información General                                                  │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │                                                                      │  │
│  │ Código:         [105.01                                         ]    │  │
│  │                                                                      │  │
│  │ Nombre:         [Clientes nacionales                            ]    │  │
│  │                                                                      │  │
│  │ Tipo:           [Por Cobrar (Receivable)                       ▼]    │  │
│  │                                                                      │  │
│  │ Grupo:          [105 - Clientes                                ▼]    │  │
│  │                                                                      │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │ Configuración                                                        │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │                                                                      │  │
│  │ [✓] Permitir reconciliación                                          │  │
│  │                                                                      │  │
│  │ [ ] Cuenta deprecada                                                 │  │
│  │                                                                      │  │
│  │ [ ] Cuenta no comercial (otros receivables)                          │  │
│  │                                                                      │  │
│  │ Moneda:         [                                              ▼]    │  │
│  │                 (Vacío = multi-moneda)                               │  │
│  │                                                                      │  │
│  │ Impuestos por defecto:                                               │  │
│  │                 [                                              ▼]    │  │
│  │                                                                      │  │
│  │ Tags:           [+ Agregar tag                                 ▼]    │  │
│  │                                                                      │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │ Uso de la Cuenta                                            📊       │  │
│  ├──────────────────────────────────────────────────────────────────────┤  │
│  │                                                                      │  │
│  │ Asientos contables: 1,234                                            │  │
│  │ Último movimiento: 2025-01-14                                        │  │
│  │ Saldo actual: $456,789.00 MXN                                        │  │
│  │                                                                      │  │
│  │                              [Ver movimientos →]                     │  │
│  │                                                                      │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

6. Datos de Plantillas

6.1 Plantilla México (extracto)

// chart_template_data: template_code='mx', model_name='account'
[
  {
    "external_id": "mx.cuenta101_01",
    "data": {
      "code": "101.01",
      "name": "Caja y efectivo",
      "account_type": "asset_cash",
      "reconcile": false
    }
  },
  {
    "external_id": "mx.cuenta102_01",
    "data": {
      "code": "102.01",
      "name": "Bancos nacionales",
      "account_type": "asset_cash",
      "reconcile": true
    }
  },
  {
    "external_id": "mx.cuenta105_01",
    "data": {
      "code": "105.01",
      "name": "Clientes nacionales",
      "account_type": "asset_receivable",
      "reconcile": true
    }
  },
  {
    "external_id": "mx.cuenta118_01",
    "data": {
      "code": "118.01",
      "name": "IVA acreditable pagado",
      "account_type": "asset_current",
      "reconcile": false
    }
  },
  {
    "external_id": "mx.cuenta201_01",
    "data": {
      "code": "201.01",
      "name": "Proveedores nacionales",
      "account_type": "liability_payable",
      "reconcile": true
    }
  },
  {
    "external_id": "mx.cuenta208_01",
    "data": {
      "code": "208.01",
      "name": "IVA trasladado cobrado",
      "account_type": "liability_current",
      "reconcile": false
    }
  },
  {
    "external_id": "mx.cuenta401_01",
    "data": {
      "code": "401.01",
      "name": "Ventas y/o servicios gravados a la tasa general",
      "account_type": "income",
      "reconcile": false
    }
  },
  {
    "external_id": "mx.cuenta601_84",
    "data": {
      "code": "601.84",
      "name": "Otros gastos generales",
      "account_type": "expense",
      "reconcile": false
    }
  }
]

6.2 Grupos de Cuentas México

// chart_template_data: template_code='mx', model_name='account_group'
[
  {
    "external_id": "mx.group_activos",
    "data": {
      "name": "Activos",
      "code_prefix_start": "1"
    }
  },
  {
    "external_id": "mx.group_activo_corto",
    "data": {
      "name": "Activo a corto plazo",
      "code_prefix_start": "100",
      "code_prefix_end": "199",
      "parent_id": "ref:mx.group_activos"
    }
  },
  {
    "external_id": "mx.group_caja",
    "data": {
      "name": "Caja",
      "code_prefix_start": "101",
      "parent_id": "ref:mx.group_activo_corto"
    }
  },
  {
    "external_id": "mx.group_bancos",
    "data": {
      "name": "Bancos",
      "code_prefix_start": "102",
      "parent_id": "ref:mx.group_activo_corto"
    }
  },
  {
    "external_id": "mx.group_clientes",
    "data": {
      "name": "Clientes",
      "code_prefix_start": "105",
      "parent_id": "ref:mx.group_activo_corto"
    }
  },
  {
    "external_id": "mx.group_pasivos",
    "data": {
      "name": "Pasivos",
      "code_prefix_start": "2"
    }
  },
  {
    "external_id": "mx.group_ingresos",
    "data": {
      "name": "Ingresos",
      "code_prefix_start": "4"
    }
  },
  {
    "external_id": "mx.group_gastos",
    "data": {
      "name": "Gastos",
      "code_prefix_start": "6"
    }
  }
]

6.3 Diarios México

// chart_template_data: template_code='mx', model_name='journal'
[
  {
    "external_id": "mx.journal_sale",
    "data": {
      "name": "Facturas de Cliente",
      "code": "FV",
      "type": "sale",
      "show_on_dashboard": true,
      "color": 11,
      "sequence": 5
    }
  },
  {
    "external_id": "mx.journal_purchase",
    "data": {
      "name": "Facturas de Proveedor",
      "code": "FC",
      "type": "purchase",
      "show_on_dashboard": true,
      "sequence": 6
    }
  },
  {
    "external_id": "mx.journal_bank",
    "data": {
      "name": "Banco",
      "code": "BNK",
      "type": "bank",
      "default_account_id": "ref:mx.cuenta102_01",
      "show_on_dashboard": true,
      "sequence": 7
    }
  },
  {
    "external_id": "mx.journal_cash",
    "data": {
      "name": "Caja",
      "code": "CAJA",
      "type": "cash",
      "default_account_id": "ref:mx.cuenta101_01",
      "show_on_dashboard": true,
      "sequence": 8
    }
  },
  {
    "external_id": "mx.journal_misc",
    "data": {
      "name": "Operaciones Varias",
      "code": "MISC",
      "type": "general",
      "sequence": 9
    }
  },
  {
    "external_id": "mx.journal_caba",
    "data": {
      "name": "Efectivamente Pagado",
      "code": "CBMX",
      "type": "general",
      "default_account_id": "ref:mx.cuenta118_01",
      "show_on_dashboard": false,
      "sequence": 20
    }
  }
]

7. Casos de Prueba

7.1 Instalación de Plantilla

async def test_install_mexico_template():
    """Prueba instalación de plantilla México."""
    loader = ChartTemplateLoader(db)

    result = await loader.load(
        template_code='mx',
        tenant_id=test_tenant_id
    )

    assert result.success is True
    assert result.accounts_created > 300  # México tiene ~325 cuentas
    assert result.groups_created > 50
    assert result.taxes_created > 20
    assert result.journals_created >= 5
    assert len(result.errors) == 0

    # Verificar configuración de empresa
    config = await db.get_company_chart_config(test_tenant_id)
    assert config['chart_template_code'] == 'mx'
    assert config['anglo_saxon_accounting'] is True
    assert config['property_account_receivable_id'] is not None

7.2 Jerarquía de Grupos

async def test_account_group_hierarchy():
    """Prueba jerarquía de grupos de cuentas."""
    # Instalar plantilla
    await loader.load('mx', tenant_id)

    # Obtener árbol de grupos
    tree = await db.get_account_groups_tree(tenant_id)

    # Verificar estructura
    activos = next(g for g in tree if g['name'] == 'Activos')
    assert activos['code_prefix_start'] == '1'

    activo_corto = next(
        c for c in activos['children']
        if c['name'] == 'Activo a corto plazo'
    )
    assert activo_corto['code_prefix_start'] == '100'
    assert activo_corto['code_prefix_end'] == '199'

    caja = next(
        c for c in activo_corto['children']
        if c['name'] == 'Caja'
    )
    assert caja['code_prefix_start'] == '101'

7.3 Sincronización de Grupos

async def test_sync_account_groups():
    """Prueba sincronización de cuentas a grupos."""
    syncer = AccountGroupSync(db)

    # Crear cuenta fuera de grupo
    new_account = await db.create_account({
        'tenant_id': tenant_id,
        'code': '101.03',
        'name': 'Caja nueva',
        'account_type': 'asset_cash'
    })

    assert new_account['group_id'] is None

    # Sincronizar
    await syncer.sync_groups(tenant_id)

    # Verificar asignación
    updated = await db.get_account(new_account['id'])
    group = await db.get_account_group(updated['group_id'])

    assert group['name'] == 'Caja'
    assert group['code_prefix_start'] == '101'

7.4 Herencia de Plantillas

async def test_template_inheritance():
    """Prueba herencia de plantillas (es_pymes hereda de es_common)."""
    loader = ChartTemplateLoader(db)

    result = await loader.load(
        template_code='es_pymes',
        tenant_id=test_tenant_id
    )

    assert result.success is True

    # Verificar que heredó cuentas de es_common
    account = await db.get_account_by_code(tenant_id, '4300')
    assert account is not None
    assert account['name'] == 'Clientes'

    # Verificar que tiene cuentas específicas de es_pymes
    # (si las hubiera)

8. Plan de Implementación

Fase 1: Infraestructura (3 SP)

  • Crear tablas de plantillas y datos
  • Implementar modelo de cuentas y grupos
  • Configurar RLS

Fase 2: Cargador de Plantillas (3 SP)

  • Implementar ChartTemplateLoader
  • Resolver referencias entre registros
  • Configuración de empresa

Fase 3: Datos México (2 SP)

  • Cargar catálogo de cuentas SAT
  • Configurar grupos jerárquicos
  • Impuestos y diarios

9. Referencias

  • Odoo 18.0: account/models/chart_template.py
  • SAT México: Código Agrupador de Cuentas
  • PGC España: Plan General Contable 2008
  • Colombia: Plan Único de Cuentas (PUC)