Files
enterprise-addons/l10n_mx_reports/models/account_diot.py
T

355 lines
20 KiB
Python

# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from __future__ import division
import re
import logging
from datetime import datetime
from unicodedata import normalize
from odoo import _, models
from odoo.exceptions import RedirectWarning, UserError
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, get_lang, SQL
_logger = logging.getLogger(__name__)
def diot_country_adapt(values):
# 2025 SAT classification uses 3-letter country codes
# https://www.fiscalia.com/archivos/2025-02-06-DIOT_2025_Ayuda_Carga_Masiva.pdf
cc = values.get('country_code')
diot_country_dict = {
'MX': 'MEX', None: '',
'AF': 'AFG', 'AX': 'ALA', 'AL': 'ALB', 'DE': 'DEU', 'AD': 'AND', 'AO': 'AGO', 'AI': 'AIA', 'AQ': 'ATA', 'AG': 'ATG', 'SA': 'SAU',
'DZ': 'DZA', 'AR': 'ARG', 'AM': 'ARM', 'AW': 'ABW', 'AU': 'AUS', 'AT': 'AUT', 'AZ': 'AZE', 'BS': 'BHS', 'BD': 'BGD', 'BB': 'BRB',
'BH': 'BHR', 'BE': 'BEL', 'BZ': 'BLZ', 'BJ': 'BEN', 'BM': 'BMU', 'BY': 'BLR', 'MM': 'MMR', 'BO': 'BOL', 'BA': 'BIH', 'BW': 'BWA',
'BR': 'BRA', 'BN': 'BRN', 'BG': 'BGR', 'BF': 'BFA', 'BI': 'BDI', 'BT': 'BTN', 'CV': 'CPV', 'KH': 'KHM', 'CM': 'CMR', 'CA': 'CAN',
'QA': 'QAT', 'TD': 'TCD', 'CL': 'CHL', 'CN': 'CHN', 'CY': 'CYP', 'CO': 'COL', 'KM': 'COM', 'KP': 'PRK', 'KR': 'KOR', 'CI': 'CIV',
'CR': 'CRI', 'HR': 'HRV', 'CU': 'CUB', 'CW': 'CUW', 'DK': 'DNK', 'DM': 'DMA', 'EC': 'ECU', 'EG': 'EGY', 'SV': 'SLV', 'AE': 'ARE',
'ER': 'ERI', 'SK': 'SVK', 'SI': 'SVN', 'ES': 'ESP', 'US': 'USA', 'EE': 'EST', 'ET': 'ETH', 'PH': 'PHL', 'FI': 'FIN', 'FJ': 'FJI',
'FR': 'FRA', 'GA': 'GAB', 'GB': 'GBR', 'GM': 'GMB', 'GE': 'GEO', 'GH': 'GHA', 'GI': 'GIB', 'GD': 'GRD', 'GR': 'GRC', 'GL': 'GRL', 'GP': 'GLP',
'GU': 'GUM', 'GT': 'GTM', 'GF': 'GUF', 'GG': 'GGY', 'GN': 'GIN', 'GW': 'GNB', 'GQ': 'GNY', 'HT': 'HTI', 'HN': 'HND', 'HK': 'HKG',
'HU': 'HUN', 'IN': 'IND', 'IQ': 'IRQ', 'IR': 'IRN', 'IE': 'IRL', 'BV': 'BVT', 'IM': 'IMN', 'CX': 'CXR', 'NF': 'NFK', 'IS': 'ISL',
'KY': 'CYM', 'CC': 'CCK', 'CK': 'COK', 'FO': 'FRO', 'GS': 'SGS', 'HM': 'HMD', 'FK': 'FLK', 'MP': 'MNP', 'MH': 'MHL', 'PN': 'PCN',
'SB': 'SLB', 'TC': 'TCA', 'UM': 'UMI', 'VG': 'VGB', 'VI': 'VIR', 'IL': 'ISR', 'IT': 'ITA', 'JM': 'JAM', 'JP': 'JPN', 'JE': 'JEY',
'JO': 'JOR', 'KZ': 'KAZ', 'KE': 'KEN', 'KG': 'KGZ', 'KI': 'KIR', 'KW': 'KWT', 'LA': 'LAO', 'LS': 'LSO', 'LV': 'LVA', 'LB': 'LBA',
'LR': 'LBR', 'LY': 'LBY', 'LI': 'LIE', 'LT': 'LTU', 'LU': 'LUX', 'MO': 'MAC', 'MG': 'MDG', 'MY': 'MYS', 'MW': 'MWI', 'MV': 'MDV',
'ML': 'MLI', 'MT': 'MLT', 'MA': 'MAR', 'MQ': 'MTQ', 'MU': 'MUS', 'MR': 'MRT', 'YT': 'MYT', 'FM': 'FSM', 'MD': 'MDA', 'MC': 'MCO',
'MN': 'MNG', 'ME': 'MNE', 'MS': 'MSR', 'MZ': 'MOZ', 'NA': 'NAM', 'NR': 'NRU', 'NP': 'NPL', 'NI': 'NIC', 'NE': 'NER', 'NG': 'NGA',
'NU': 'NIU', 'NO': 'NOR', 'NC': 'NCL', 'NZ': 'NZL', 'OM': 'OMN', 'NL': 'NLD', 'PK': 'PAK', 'PW': 'PLW', 'PS': 'PSE', 'PA': 'PAN',
'PG': 'PNG', 'PY': 'PRY', 'PE': 'PER', 'PF': 'PYF', 'PL': 'POL', 'PT': 'PRT', 'PR': 'PRI', 'CF': 'CAF', 'CZ': 'CZE', 'MK': 'MKD',
'CG': 'COG', 'CD': 'COD', 'DR': 'DOM', 'RE': 'REU', 'RW': 'RWA', 'RO': 'ROU', 'RU': 'RUS', 'EH': 'ESH', 'WS': 'WSM', 'AS': 'ASM',
'BL': 'BLM', 'KN': 'KNA', 'SM': 'SMR', 'MF': 'MAF', 'PM': 'SPM', 'VC': 'VCT', 'SH': 'SHN', 'LC': 'LCA', 'ST': 'STP', 'SN': 'SEN',
'RS': 'SRB', 'SC': 'SYC', 'SL': 'SLE', 'SG': 'SGP', 'SX': 'SXM', 'SY': 'SYR', 'SO': 'SOM', 'LK': 'LKA', 'SZ': 'SWZ', 'ZA': 'ZAF',
'SD': 'SDN', 'SS': 'SSD', 'SE': 'SWE', 'CH': 'CHE', 'SR': 'SUR', 'SJ': 'SJM', 'TH': 'THA', 'TW': 'TWA', 'TZ': 'TZA', 'TJ': 'TJK',
'IO': 'IOT', 'TF': 'ATF', 'TL': 'TLS', 'TG': 'TGO', 'TK': 'TKL', 'TO': 'TON', 'TT': 'TTO', 'TN': 'TUN', 'TM': 'TKM', 'TR': 'TUR',
'TV': 'TUV', 'UA': 'UKR', 'UG': 'UGA', 'UY': 'URY', 'UZ': 'UZB', 'VU': 'VUT', 'VA': 'VAT', 'VE': 'VEN', 'VN': 'VNM', 'WF': 'WLF',
'YE': 'YEM', 'DJ': 'DJY', 'ZM': 'ZMB', 'ZW': 'ZWE'
}
if cc not in diot_country_dict:
# Country not in DIOT catalog, so we use the special code 'ZZZ'
# for 'Other' countries
values['country_code'] = 'ZZZ'
else:
# Map the standard country_code to the SAT standard
values['country_code'] = diot_country_dict.get(cc, cc)
return values
class L10n_MxReportHandler(models.AbstractModel):
_name = 'l10n_mx.report.handler'
_inherit = ['account.tax.report.handler']
_description = 'Mexican Account Report Custom Handler'
def _custom_options_initializer(self, report, options, previous_options):
super()._custom_options_initializer(report, options, previous_options)
options['columns'] = [column for column in options['columns']]
options.setdefault('buttons', []).extend((
{'name': _('DIOT (txt)'), 'sequence': 40, 'action': 'export_file', 'action_param': 'action_get_diot_txt', 'file_export_type': _('DIOT')},
{'name': _('DPIVA (txt)'), 'sequence': 60, 'action': 'export_file', 'action_param': 'action_get_dpiva_txt', 'file_export_type': _('DPIVA')},
))
def _report_custom_engine_diot_report(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None):
def build_dict(report, current_groupby, query_res):
if not current_groupby:
return query_res[0] if query_res else {k: None for k in report.mapped('line_ids.expression_ids.label')}
return [(group_res["grouping_key"], group_res) for group_res in query_res]
report = self.env['account.report'].browse(options['report_id'])
query_res = self._execute_query(report, current_groupby, options, offset, limit)
return build_dict(report, current_groupby, query_res)
def _execute_query(self, report, current_groupby, options, offset, limit):
report._check_groupby_fields([current_groupby] if current_groupby else [])
# This report mixes the tax_tags and custom engine.
# The results of the custom engine are relevant
# only when we have a partner. Else, we default to ''.
if current_groupby != 'partner_id':
return []
query = report._get_report_query(options, 'strict_range', domain=[
('parent_state', '=', 'posted'),
])
country_demonym = self.env['res.country']._field_to_sql('country', 'demonym')
tags = report.line_ids.expression_ids._get_matching_tags()
tail_query = report._get_engine_query_tail(offset, limit)
self.env.cr.execute(SQL(
"""
WITH raw_results as (
SELECT
account_move_line.partner_id AS grouping_key,
CASE WHEN country.code = 'MX' THEN '04' ELSE '05' END AS third_party_code,
partner.l10n_mx_type_of_operation AS operation_type_code,
partner.vat AS partner_vat_number,
country.code AS country_code,
%(country_demonym)s AS partner_nationality
FROM %(table_references)s
JOIN account_move AS move ON move.id = account_move_line.move_id
JOIN account_account_tag_account_move_line_rel AS tag_aml_rel ON account_move_line.id = tag_aml_rel.account_move_line_id
JOIN account_account_tag AS tag ON tag.id = tag_aml_rel.account_account_tag_id AND tag.id IN %(tags)s
JOIN res_partner AS partner ON partner.id = account_move_line.partner_id
LEFT JOIN res_country AS country ON country.id = partner.country_id
WHERE %(search_condition)s
ORDER BY partner.name, account_move_line.date, account_move_line.id
)
SELECT
raw_results.grouping_key AS grouping_key,
count(raw_results.grouping_key) AS counter,
raw_results.third_party_code AS third_party_code,
raw_results.operation_type_code AS operation_type_code,
COALESCE(raw_results.partner_vat_number, '') AS partner_vat_number,
raw_results.country_code AS country_code,
raw_results.partner_nationality AS partner_nationality
FROM raw_results
GROUP BY
%(groupby_sql)s
ORDER BY
%(groupby_sql)s
%(tail_query)s
""",
country_demonym=country_demonym,
table_references=query.from_clause,
tags=tuple(tags.ids),
search_condition=query.where_clause,
groupby_sql=SQL("""
raw_results.grouping_key,
raw_results.third_party_code,
raw_results.operation_type_code,
raw_results.partner_vat_number,
raw_results.country_code,
raw_results.partner_nationality
"""),
tail_query=tail_query,
))
return [diot_country_adapt(vals) for vals in self.env.cr.dictfetchall()]
def action_get_diot_txt(self, options):
report = self.env['account.report'].browse(options['report_id'])
partner_and_values_to_report = self._get_diot_values_per_partner(report, options)
self.check_for_error_on_partner(list(partner_and_values_to_report))
lines = []
data_87 = [0] * 54
for partner, values in partner_and_values_to_report.items():
if not sum(values.get(x, 0) for x in (
'paid_8', 'paid_8_non_cred', 'paid_8_tax', 'paid_8_non_cred_tax', 'refunds_8_n',
'paid_8_s', 'paid_8_s_nc', 'paid_8_s_tax', 'paid_8_s_nc_tax', 'refunds_8_s',
'paid_16', 'paid_16_non_cred', 'paid_16_tax', 'paid_16_non_cred_tax', 'refunds_16',
'importation_16', 'paid_16_imp_nc', 'importation_16_tax', 'paid_16_imp_nc_tax', 'refunds_16_imp',
'paid_16_imp_int', 'paid_16_imp_int_nc', 'paid_16_imp_int_tax', 'paid_16_imp_int_nc_tax', 'refunds_16_imp_int',
'withheld', 'exempt', 'exempt_imp', 'paid_0', 'no_obj'
)):
# don't report if there isn't any amount to report
continue
data = [0] * 54
if values.get('operation_type_code') != '87':
self.l10n_mx_diot_get_values(values, data, partner)
for i in range(7, 53):
if not data[i]:
data[i] = ''
if not isinstance(data[i], str):
data[i] = str(round(data[i]))
lines.append('|'.join(str(d) for d in data))
else:
self.l10n_mx_diot_get_values(values, data_87, partner)
# Global Operations
if any(data_87):
for i in range(7, 53):
if not data_87[i]:
data_87[i] = ''
if not isinstance(data_87[i], str):
data_87[i] = str(round(data_87[i]))
lines.append('|'.join(str(d) for d in data_87))
diot_txt_result = '\n'.join(lines)
return {
'file_name': report.get_default_report_filename(options, 'txt'),
'file_content': diot_txt_result.encode(),
'file_type': 'txt',
}
def l10n_mx_diot_get_values(self, values, data, partner):
is_foreign_partner = values.get('third_party_code') != '04'
is_global = values.get('operation_type_code') == '87'
# Non-numerical
data[0] = values.get('third_party_code') # Supplier Type
data[1] = values.get('operation_type_code') # Operation Type
data[2] = 'XAXX010101000' if is_global else (values.get('partner_vat_number') if not is_foreign_partner else '') # Tax Number
data[3] = '' if is_global else (values.get('partner_vat_number') if is_foreign_partner else '') # Tax Number for Foreigners
data[4] = '' if is_global else (''.join(self.str_format(partner.name)).encode('utf-8').strip().decode('utf-8') if is_foreign_partner else '') # Name
data[5] = '' if is_global else (values.get('country_code') if is_foreign_partner else '') # Country
data[6] = '' # Fiscal Jurisdiction specified (for manual entry)
# Sums and refunds
data[7] += float(values.get('paid_8_n_wnc', 0)) # 8% Northern +NC paid
data[8] += float(values.get('refunds_8_n', 0)) # 8% Northern refunds
data[9] += float(values.get('paid_8_s_wnc', 0)) # 8% Southern +NC paid
data[10] += float(values.get('refunds_8_s', 0)) # 8% Southern refunds
data[11] += float(values.get('paid_16_wnc', 0)) # 16% +NC paid
data[12] += float(values.get('refunds_16', 0)) # 16% refunds
data[13] += float(values.get('paid_16_imp_wnc', 0)) # 16% imports +NC paid
data[14] += float(values.get('refunds_16_imp', 0)) # 16% imports refunds
data[15] += float(values.get('paid_16_imp_int_wnc', 0)) # 16% intangible imports +NC paid
data[16] += float(values.get('refunds_16_imp_int', 0)) # 16% int imp refunds
# Creditable VAT
data[17] += float(values.get('paid_8_tax', 0)) # 8% Northern paid
data[19] += float(values.get('paid_8_s_tax', 0)) # 8% Southern paid
data[21] += float(values.get('paid_16_tax', 0)) # 16% VAT exclusive base
data[23] += float(values.get('importation_16_tax', 0)) # 16% import VAT exclusive base
data[25] += float(values.get('paid_16_imp_int_tax', 0)) # 16% int imp VAT exclusive base
# Non-creditable VAT
data[27] += float(values.get('paid_8_non_cred_tax', 0)) # 8% Northern NC paid
data[31] += float(values.get('paid_8_s_nc_tax', 0)) # 8% Southern NC paid
data[35] += float(values.get('paid_16_non_cred_tax', 0)) # 16% NC paid
data[39] += float(values.get('paid_16_imp_nc_tax', 0)) # 16% imports NC paid
data[43] += float(values.get('paid_16_imp_int_nc_tax', 0)) # 16% non tangible imports NC paid
# Additional data
data[47] += float(values.get('withheld', 0)) # DIOT:Retention base
data[48] += float(values.get('exempt_imp', 0)) # DIOT:Import Exempt base
data[49] += float(values.get('exempt', 0)) # DIOT:Exempt base
data[50] += float(values.get('paid_0', 0)) # 0% payments
data[51] += float(values.get('no_obj', 0)) # No tax object
# Declaration
data[53] = '02' if is_global else '01' # "Hereby I declare that I rightfully credited VAT"
def action_get_dpiva_txt(self, options):
report = self.env['account.report'].browse(options['report_id'])
partner_and_values_to_report = self._get_diot_values_per_partner(report, options)
self.check_for_error_on_partner([partner for partner in partner_and_values_to_report])
date = datetime.strptime(options['date']['date_from'], DEFAULT_SERVER_DATE_FORMAT)
month = {
'01': 'Enero',
'02': 'Febrero',
'03': 'Marzo',
'04': 'Abril',
'05': 'Mayo',
'06': 'Junio',
'07': 'Julio',
'08': 'Agosto',
'09': 'Septiembre',
'10': 'Octubre',
'11': 'Noviembre',
'12': 'Diciembre',
}.get(date.strftime("%m"))
lines = []
for partner, values in partner_and_values_to_report.items():
if not any(values.get(x) for x in (
'paid_16_tax', 'paid_16_non_cred_tax', 'paid_8_tax', 'paid_8_non_cred_tax', 'importation_16_tax',
'paid_0', 'exempt', 'withheld', 'refunds_8_n', 'refunds_16', 'refunds_16_imp'
)):
# don't report if there isn't any amount to report
continue
is_foreign_partner = values['third_party_code'] != '04'
data = [''] * 48
data[0] = '1.0' # Version
data[1] = f"{date.year}" # Fiscal Year
data[2] = 'MES' # Cabling value
data[3] = month # Period
data[4] = '1' # 1 Because has data
data[5] = '1' # 1 = Normal, 2 = Complementary (Not supported now).
data[8] = values['counter'] # Count the operations
for num in range(9, 26):
data[num] = '0'
data[26] = values['third_party_code'] # Supplier Type
data[27] = values['operation_type_code'] # Operation Type
data[28] = values['partner_vat_number'] if not is_foreign_partner else '' # Federal Taxpayer Registry Code
data[29] = values['partner_vat_number'] if is_foreign_partner else '' # Fiscal ID
data[30] = ''.join(self.str_format(partner.name)).encode('utf-8').strip().decode('utf-8') if is_foreign_partner else '' # Name
data[31] = values['country_code'] if is_foreign_partner else '' # Country
data[32] = ''.join(self.str_format(values['partner_nationality'])).encode('utf-8').strip().decode('utf-8') if is_foreign_partner else '' # Nationality
data[33] = round(float(values.get('paid_16_tax', 0))) or '' # 16%
data[36] = round(float(values.get('paid_8_tax', 0))) or '' # 8%
data[39] = round(float(values.get('importation_16_tax', 0))) or '' # 16% - Importation
data[44] = round(float(values.get('paid_0', 0))) or '' # 0%
data[45] = round(float(values.get('exempt', 0))) or '' # Exempt
data[46] = round(float(values.get('withheld', 0))) or '' # Withheld
data[47] = round(float(values.get('refunds_8_n', 0)) + float(values.get('refunds_16', 0)) + float(values.get('refunds_16_imp', 0))) or '' # Refunds
lines.append('|{}|'.format('|'.join(str(d) for d in data)))
dpiva_txt_result = '\n'.join(lines)
return {
'file_name': report.get_default_report_filename(options, 'txt'),
'file_content': dpiva_txt_result.encode(),
'file_type': 'txt',
}
def _get_diot_values_per_partner(self, report, options):
options['unfolded_lines'] = [] # This allows to only get the first groupby level: partner_id
col_group_results = report._compute_expression_totals_for_each_column_group(report.line_ids.expression_ids, options, groupby_to_expand="partner_id")
if len(col_group_results) != 1:
raise UserError(_("You can only export one period at a time with this file format!"))
expression_list = list(col_group_results.values())
label_dict = {exp.label: v['value'] for d in expression_list for exp, v in d.items()}
partner_to_label_val = {}
for label, partner_to_value_list in label_dict.items():
for partner_id, value in partner_to_value_list:
if not partner_id:
raise UserError(_("The report cannot be generated because there are entries with tax amounts but no partner assigned."))
partner_to_label_val.setdefault(self.env['res.partner'].browse(partner_id), {})[label] = value
return dict(sorted(partner_to_label_val.items(), key=lambda item: item[0].name))
def check_for_error_on_partner(self, partners):
partner_missing_information = self.env['res.partner']
for partner in partners:
if partner.country_id.code == "MX" and not partner.vat:
partner_missing_information += partner
if not partner.l10n_mx_type_of_operation:
partner_missing_information += partner
if partner_missing_information:
action_error = {
'name': _('Partner missing informations'),
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'view_mode': 'list',
'views': [(False, 'list'), (False, 'form')],
'domain': [('id', 'in', partner_missing_information.ids)],
}
msg = _('The report cannot be generated because some partners are missing a valid RFC or type of operation')
raise RedirectWarning(msg, action_error, _("See the list of partners"))
@staticmethod
def str_format(text):
if not text:
return ''
trans_tab = {
ord(char): None for char in (
u'\N{COMBINING GRAVE ACCENT}',
u'\N{COMBINING ACUTE ACCENT}',
u'\N{COMBINING DIAERESIS}',
)
}
text_n = normalize('NFKC', normalize('NFKD', text).translate(trans_tab))
check_re = re.compile(r'''[^A-Za-z\d Ññ]''')
return check_re.sub('', text_n)