Espero se en
Solución: Generación correcta del Catálogo de Cuentas XML SAT en Odoo 16.0+ (l10n_mx_reports)
Problema resuelto
Esta solución corrige el error de validación del catálogo de cuentas XML SAT que muestra:
Primero: este error se soluciona definiendo si la cuenta es de debito o crédito en la etiqueta de la cuenta:
Debe de verse asi:
Segundo error, este es por que hay cuentas que están como crédito pero de acuerdo al tipo que esta configurada debería de ser débito o viceversa, tambien puede ser por que existen 2 cuentas con exactamente el mismo código agrupador
Tercero, parece que el addon tiene algunos errores para la versión 16, por lo que hay que ralizar algunas modificaciones, aqui tienes el error que muestra:
Requisitos previos
- Odoo 16.0+ Enterprise con módulo l10n_mx_reports
- Permisos de administrador del sistema
- Acceso SSH al servidor
- Las cuentas contables deben tener etiquetas de naturaleza (Deudora/Acreedora)
Archivos a modificar
1. Archivo Python del manejador
Ruta: /usr/lib/python3/dist-packages/odoo/addons/l10n_mx_reports/models/trial_balance.py
Respalde el archivo original:
bash
sudo cp /usr/lib/python3/dist-packages/odoo/addons/l10n_mx_reports/models/trial_balance.py \ /usr/lib/python3/dist-packages/odoo/addons/l10n_mx_reports/models/trial_balance.py.backup
Reemplacelo con el siguiente codigo:
# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from lxml import etree
from collections import defaultdict
from odoo import models, fields, _
from odoo.exceptions import UserError, RedirectWarning
class TrialBalanceCustomHandler(models.AbstractModel):
_inherit = 'account.trial.balance.report.handler'
def _custom_options_initializer(self, report, options, previous_options=None):
super()._custom_options_initializer(report, options, previous_options)
if self.env.company.account_fiscal_country_id.code == 'MX':
options['buttons'] += [
{'name': _("SAT (XML)"), 'action': 'export_file', 'action_param': 'action_l10n_mx_generate_sat_xml', 'file_export_type': _("SAT (XML)"), 'sequence': 15},
{'name': _("COA SAT (XML)"), 'action': 'export_file', 'action_param': 'action_l10n_mx_generate_coa_sat_xml', 'file_export_type': _("COA SAT (XML)"), 'sequence': 16},
]
def action_l10n_mx_generate_sat_xml(self, options):
if self.env.company.account_fiscal_country_id.code != 'MX':
raise UserError(_("Only Mexican company can generate SAT report."))
sat_values = self._l10n_mx_get_sat_values(options)
file_name = f"{sat_values['vat']}{sat_values['year']}{sat_values['month']}BN"
sat_report = etree.fromstring(self.env['ir.qweb']._render('l10n_mx_reports.cfdibalance', sat_values))
self.env['ir.attachment'].l10n_mx_reports_validate_xml_from_attachment(sat_report, 'xsd_mx_cfdibalance_1_3.xsd')
return {
'file_name': f"{file_name}.xml",
'file_content': etree.tostring(sat_report, pretty_print=True, xml_declaration=True, encoding='utf-8'),
'file_type': 'xml',
}
def _l10n_mx_get_sat_values(self, options):
report = self.env['account.report'].browse(options['report_id'])
sat_options = self._l10n_mx_get_sat_options(options)
report_lines = report._get_lines(sat_options)
# The SAT code has to be of the form XXX.YY . Any additional suffixes are allowed, but if the line starts
# with anything else it should not be included in the SAT report.
sat_code = re.compile(r'((\d{3})\.\d{2})')
account_lines = []
parents = defaultdict(lambda: defaultdict(int))
for line in [line for line in report_lines if line.get('level') == 4]:
dummy, res_id = report._get_model_info_from_id(line['id'])
account = self.env['account.account'].browse(res_id)
is_credit_account = any([account.account_type.startswith(acc_type) for acc_type in ['liability', 'equity', 'income']])
balance_sign = -1 if is_credit_account else 1
cols = line.get('columns', [])
# Initial Debit - Initial Credit = Initial Balance
initial = balance_sign * (cols[0].get('no_format', 0.0) - cols[1].get('no_format', 0.0))
# Debit and Credit of the selected period
debit = cols[2].get('no_format', 0.0)
credit = cols[3].get('no_format', 0.0)
# End Debit - End Credit = End Balance
end = balance_sign * (cols[4].get('no_format', 0.0) - cols[5].get('no_format', 0.0))
pid_match = sat_code.match(line['name'])
if not pid_match:
raise UserError(_("Invalid SAT code: %s", line['name']))
for pid in pid_match.groups():
parents[pid]['initial'] += initial
parents[pid]['debit'] += debit
parents[pid]['credit'] += credit
parents[pid]['end'] += end
for pid in sorted(parents.keys()):
account_lines.append({
'number': pid,
'initial': '%.2f' % parents[pid]['initial'],
'debit': '%.2f' % parents[pid]['debit'],
'credit': '%.2f' % parents[pid]['credit'],
'end': '%.2f' % parents[pid]['end'],
})
report_date = fields.Date.to_date(sat_options['date']['date_from'])
return {
'vat': self.env.company.vat or '',
'month': str(report_date.month).zfill(2),
'year': report_date.year,
'type': 'N',
'accounts': account_lines,
}
def action_l10n_mx_generate_coa_sat_xml(self, options):
if self.env.company.account_fiscal_country_id.code != 'MX':
raise UserError(_("Only Mexican company can generate SAT report."))
coa_values = self._l10n_mx_get_coa_values(options)
file_name = f"{coa_values['vat']}{coa_values['year']}{coa_values['month']}CT"
# Generar el XML con orden específico de atributos
root = etree.Element(
"{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas}Catalogo",
nsmap={
None: "http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas",
'xsi': "http://www.w3.org/2001/XMLSchema-instance",
'catalogocuentas': "http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas"
}
)
# Establecer atributos en orden específico
root.set("{http://www.w3.org/2001/XMLSchema-instance}schemaLocation",
"http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas/CatalogoCuentas_1_3.xsd")
root.set("Version", "1.3")
root.set("RFC", coa_values['vat'])
root.set("Mes", coa_values['month'])
root.set("Anio", str(coa_values['year']))
# Agregar cuentas con atributos en orden específico
for account in coa_values['accounts']:
if all([account.get('number'), account.get('code'), account.get('name'),
account.get('nature'), account.get('level')]):
cta = etree.SubElement(root, "{http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas}Ctas")
# Orden específico: CodAgrup, NumCta, Desc, Nivel, Natur
cta.set("CodAgrup", account.get('number'))
cta.set("NumCta", account.get('code'))
cta.set("Desc", account.get('name'))
cta.set("Nivel", str(account.get('level')))
cta.set("Natur", account.get('nature'))
self.env['ir.attachment'].l10n_mx_reports_validate_xml_from_attachment(root, 'xsd_mx_cfdicoa_1_3.xsd')
return {
'file_name': f"{file_name}.xml",
'file_content': etree.tostring(root, pretty_print=True, xml_declaration=True, encoding='utf-8'),
'file_type': 'xml',
}
def _l10n_mx_get_coa_values(self, options):
# Checking if debit/credit tags are installed
debit_balance_account_tag = self.env.ref('l10n_mx.tag_debit_balance_account', raise_if_not_found=False)
credit_balance_account_tag = self.env.ref('l10n_mx.tag_credit_balance_account', raise_if_not_found=False)
if not debit_balance_account_tag or not credit_balance_account_tag:
raise UserError(_("Missing Debit or Credit balance account tag in database."))
coa_options = self._l10n_mx_get_sat_options(options)
accounts = self.env['account.account'].search([
('company_id', '=', self.env.company.id),
('account_type', '!=', 'equity_unaffected'),
('group_id', '!=', False),
])
accounts_template_data = []
processed_groups = set() # Para evitar duplicados
no_tag_accounts = self.env['account.account'] # Cuentas sin etiquetas
multi_tag_accounts = self.env['account.account'] # Cuentas con múltiples etiquetas
for account in accounts:
# Determinar la naturaleza de la cuenta
nature = ''
if debit_balance_account_tag in account.tag_ids:
nature += 'D'
if credit_balance_account_tag in account.tag_ids:
nature += 'A'
# Validar etiquetas
if not nature:
no_tag_accounts |= account
continue # Saltar cuentas sin etiqueta
elif len(nature) > 1:
multi_tag_accounts |= account
continue # Saltar cuentas con múltiples etiquetas conflictivas
# Obtener y limpiar el código de agrupación del grupo
cod_agrup_raw = account.group_id.code_prefix_start or ''
# Limpiar el código para cumplir con formato SAT
if '.' in cod_agrup_raw:
parts = cod_agrup_raw.split('.')
# Tomar solo los primeros dos niveles
clean_parts = [p for p in parts[:2] if p and p != '000']
if len(clean_parts) == 2 and clean_parts[1] != '00':
cod_agrup = '.'.join(clean_parts)
nivel = 2
elif len(clean_parts) >= 1:
cod_agrup = clean_parts[0]
nivel = 1
else:
continue # Saltar si no hay código válido
else:
cod_agrup = cod_agrup_raw
nivel = 1 if len(cod_agrup_raw) == 3 else 2
# Agregar primero las cuentas de grupo si no se han procesado
if cod_agrup not in processed_groups and account.group_id:
processed_groups.add(cod_agrup)
# Si es nivel 2, también agregar el nivel 1 si no existe
if nivel == 2 and '.' in cod_agrup:
parent_code = cod_agrup.split('.')[0]
if parent_code not in processed_groups and account.group_id.parent_id:
processed_groups.add(parent_code)
accounts_template_data.append({
'number': parent_code, # CodAgrup (plantilla invierte los campos)
'code': parent_code, # NumCta
'name': account.group_id.parent_id.name,
'level': 1,
'nature': nature, # Heredar naturaleza
})
# Agregar el grupo actual
accounts_template_data.append({
'number': cod_agrup, # CodAgrup (plantilla invierte los campos)
'code': cod_agrup, # NumCta
'name': account.group_id.name,
'level': nivel,
'nature': nature,
})
# Agregar la cuenta individual
accounts_template_data.append({
'number': cod_agrup, # CodAgrup (plantilla invierte los campos)
'code': account.code, # NumCta - código real de la cuenta
'name': account.name, # Desc
'level': 3, # Las cuentas individuales son nivel 3
'nature': nature,
})
# Validaciones y mensajes de error
if no_tag_accounts:
raise RedirectWarning(
_("Some accounts present in your trial balance don't have a Debit or a Credit balance account tag."),
{
'name': _("Accounts without tag"),
'type': 'ir.actions.act_window',
'views': [(False, 'list'), (False, 'form')],
'res_model': 'account.account',
'target': 'current',
'domain': [('id', 'in', no_tag_accounts.ids)],
},
_('Show list')
)
if multi_tag_accounts:
raise RedirectWarning(
_("Some accounts have both Debit and Credit balance account tags. This is not allowed."),
{
'name': _("Accounts with conflicting tags"),
'type': 'ir.actions.act_window',
'views': [(False, 'list'), (False, 'form')],
'res_model': 'account.account',
'target': 'current',
'domain': [('id', 'in', multi_tag_accounts.ids)],
},
_('Show list')
)
# Ordenar por código para mejor presentación
accounts_template_data.sort(key=lambda x: (x['number'], x['level'], x['code']))
report_date = fields.Date.to_date(coa_options['date']['date_from'])
return {
'vat': self.env.company.vat or '',
'month': str(report_date.month).zfill(2),
'year': report_date.year,
'accounts': accounts_template_data,
}
def _l10n_mx_get_sat_options(self, options):
sat_options = options.copy()
del sat_options['comparison']
return self.env['account.report'].browse(options['report_id'])._get_options(
previous_options={
**sat_options,
'hierarchy': True, # We need the hierarchy activated to get group lines
}
)
2. Archivo de plantilla XML (opcional)
Ruta: /usr/lib/python3/dist-packages/odoo/addons/l10n_mx_reports/data/cfdicoa.xml o similar
Si desea corregir el orden de los atributos en el XML, actualice la plantilla para incluir el t-foreach:
xml
<t t-foreach="accounts" t-as="account"> <t t-if="account.get('code') and account.get('number') and account.get('name') and account.get('nature') and account.get('level')"> <catalogocuentas:Ctas t-att-CodAgrup="account.get('number')" t-att-NumCta="account.get('code')" t-att-Desc="account.get('name')" t-att-Nivel="account.get('level')" t-att-Natur="account.get('nature')"/> </t> </t>
Instalación
- Detener el servicio de Odoo:
bash
sudo systemctl stop odoo
- Aplicar los cambios (copiar el código actualizado)
- Limpiar el caché de Python:
bash
sudo find /usr/lib/python3/dist-packages/odoo -name "*.pyc" -delete sudo find /usr/lib/python3/dist-packages/odoo -name "__pycache__" -type d -exec rm -rf {} +
- Reiniciar Odoo:
bash
sudo systemctl start odoo
Cambios principales implementados
- Limpieza de códigos de agrupación SAT:
- Elimina el tercer nivel .000 que no acepta el SAT
- Convierte 100.01.000 → 100.01
- Mantiene solo los formatos XXX o XXX.YY
- Generación de estructura completa:
- Nivel 1: Grupos principales (ej: 101, 102)
- Nivel 2: Subgrupos (ej: 101.01, 102.01)
- Nivel 3: Cuentas individuales con sus códigos reales
- Validaciones agregadas:
- Detecta cuentas sin etiquetas de naturaleza (D/A)
- Detecta cuentas con etiquetas conflictivas
- Muestra ventanas emergentes para corregir problemas
- Control del orden de atributos XML:
- Genera XML con atributos en orden correcto
- CodAgrup, NumCta, Desc, Nivel, Natur
Consideraciones importantes
- Configuración de etiquetas:
- Todas las cuentas DEBEN tener una etiqueta: "Cuenta de balance deudora" O "Cuenta de balance acreedora"
- NO pueden tener ambas etiquetas
- Sin etiqueta = error al generar
- Grupos contables:
- Los grupos deben tener code_prefix_start configurado
- El formato debe seguir el estándar SAT (XXX.YY.ZZZ)
- Respaldos:
- SIEMPRE respaldar antes de modificar
- Guardar una copia del XML generado para validación
Verificación
- Generar el XML desde: Contabilidad → Informes → Balance de comprobación → COA SAT (XML)
Validar en: https://ceportalvalidacionprod.clouda.sat.gob.mx/
- El XML debe mostrar estructura como:
xml
<catalogocuentas:Ctas CodAgrup="101" NumCta="101" Desc="Caja" Nivel="1" Natur="D"/> <catalogocuentas:Ctas CodAgrup="101.01" NumCta="101.01" Desc="Caja y efectivo" Nivel="2" Natur="D"/> <catalogocuentas:Ctas CodAgrup="101.01" NumCta="101.01.001" Desc="Efectivo" Nivel="3" Natur
cuentren bien, me encontré con algunos errores