62 KiB
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
- Diseñar sistema de plantillas de plan de cuentas
- Soportar jerarquía de plantillas (herencia)
- Implementar carga automática al crear empresa
- Incluir plantillas para México, España, Colombia
- Permitir recarga/actualización de plantillas
- 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)