# 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 ```sql -- ============================================================================= -- 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 ```python # 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 ```python # 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 ```python # 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 ```yaml # 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 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 response: CompanyChartConfig ``` ### 4.2 Schemas ```typescript // 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) ```json // 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 ```json // 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 ```json // 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 ```python 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 ```python 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 ```python 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 ```python 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)