1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/l10n_mx_xml_polizas/wizard/xml_polizas_wizard.py
2024-12-10 09:04:09 +07:00

407 lines
17 KiB
Python

# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import io
import logging
import re
import textwrap
import zipfile
from collections import defaultdict, namedtuple
from datetime import date, timedelta
import stdnum.mx
from werkzeug.urls import url_encode
from odoo import _, api, fields, models, tools
from odoo.exceptions import RedirectWarning, UserError, ValidationError
# Order number is constrained by this pattern, an example is given.
ORDER_NUMBER_PATTERN = re.compile('^[A-Z]{3}[0-6][0-9][0-9]{5}(/)[0-9]{2}$')
ORDER_NUMBER_EXAMPLE = 'ABC6987654/99'
# Process number is constrained by this pattern, an example is given.
PROCESS_NUMBER_PATTERN = re.compile('^[A-Z]{2}[0-9]{12}$')
PROCESS_NUMBER_EXAMPLE = 'AB123451234512'
_logger = logging.getLogger(__name__)
class MoveExportData(defaultdict):
Period = namedtuple('Period', ['year', 'month'])
Key = namedtuple('Key', ['date', 'journal_name', 'name'])
def __init__(self):
super().__init__(lambda: defaultdict(list))
def append(self, move_date, journal_name, move_name, move_data):
period = self.Period('%04d' % move_date.year, '%02d' % move_date.month)
move_key = self.Key(date.strftime(move_date, '%Y-%m-%d'), journal_name, move_name)
self[period][move_key].append(move_data)
class XmlPolizasExportWizard(models.TransientModel):
_name = 'l10n_mx_xml_polizas.xml_polizas_wizard'
_description = "Wizard for the XML Polizas export of Journal Entries"
# Report fields
report_data = fields.Binary('Report file', readonly=True, attachment=False)
report_filename = fields.Char(string='Filename', readonly=True)
mimetype = fields.Char(string='Mimetype', readonly=True)
# Extra data for the report
export_type = fields.Selection(
string='Export type',
selection=[
('AF', 'Tax audit'),
('FC', 'Audit certification'),
('DE', 'Return of goods'),
('CO', 'Compensation'),
],
required=True)
order_number = fields.Char(
string='Order Number',
help="Attribute to express the order number assigned to the tax audit to which "
"the Journal Entry application refers.\n"
"Required for request types 'AF' and 'FC'")
process_number = fields.Char(
string='Process Number',
help="Attribute to express the process number assigned to the request for refund "
"or compensation to which the request for the Journal Entry refers.\n"
"Required for request types 'DE' and 'CO'")
# Filter status booleans
filter_partial_month = fields.Boolean(
compute='_compute_filter_partial_month',
help="The filter does not only include full months")
filter_partial_journals = fields.Boolean(
compute='_compute_filter_partial_journals',
help="The filter does not include all journals")
filter_all_entries = fields.Boolean(
compute='_compute_filter_all_entries',
help="The filter includes unposted entries")
# ------------------------------
#
# Compute functions
#
# ------------------------------
@api.depends('export_type')
def _compute_filter_all_entries(self):
""" Detects if the current filter selects unposted entries """
self.filter_all_entries = self._options.get('all_entries', False)
@api.depends('export_type')
def _compute_filter_partial_journals(self):
""" Detects if the current filter only selects one journal """
journals_options = self._options.get('journals', None)
partial_journals = False
if journals_options:
selected_journals = [x.get('selected', False) for x in journals_options if 'code' in x]
partial_journals = any(selected_journals) and not all(selected_journals)
self.filter_partial_journals = partial_journals
@api.depends('export_type')
def _compute_filter_partial_month(self):
""" Detects if the current filter selects months partially """
partial_month = False
date_options = self._options.get('date', {})
if date_options.get('mode', '') == 'range':
start = fields.Date.to_date(date_options['date_from'])
end = fields.Date.to_date(date_options['date_to'])
if start.day != 1 or (end + timedelta(1)).day != 1 or end > date.today():
partial_month = True
self.filter_partial_month = partial_month
# ------------------------------
#
# Properties
#
# ------------------------------
@property
def _options(self):
""" Get the options from the context """
return self._context.get('l10n_mx_xml_polizas_generation_options', {})
# ------------------------------
#
# Onchange
#
# ------------------------------
@api.onchange('export_type')
def _onchange_export_type(self):
""" Blanks out unrequired fields depending on the export_type """
for record in self:
if record.export_type in ['AF', 'FC']:
record.process_number = False
elif record.export_type in ['DE', 'CO']:
record.order_number = False
# ------------------------------
#
# Constraints
#
# ------------------------------
@api.constrains('order_number')
def _check_order_number(self):
""" Checks that the order number follows the ORDER_NUMBER_PATTERN """
for record in self:
if not record.order_number:
if self.export_type in ('AF', 'FC'):
selection = dict(self._fields['export_type']._description_selection(self.env))
raise ValidationError(_("Order number is required for Export Type %r or %r",
selection['AF'], selection['FC']))
elif not re.match(ORDER_NUMBER_PATTERN, record.order_number):
raise ValidationError(_("Order number (%s) is invalid, must be like: %s",
record.order_number, ORDER_NUMBER_EXAMPLE))
@api.constrains('process_number')
def _check_process_number(self):
""" Checks that the process number follows the PROCESS_NUMBER_PATTERN """
for record in self:
if not record.process_number:
if self.export_type in ('CO', 'DE'):
selection = dict(self._fields['export_type']._description_selection(self.env))
raise ValidationError(_("Process number is required for Export Type %r or %r",
selection['CO'], selection['DE']))
elif not re.match(PROCESS_NUMBER_PATTERN, record.process_number):
raise ValidationError(_("Process number (%s) is invalid, must be like: %s",
record.process_number, PROCESS_NUMBER_EXAMPLE))
# ------------------------------
#
# Data retrieval
#
# ------------------------------
def _do_query(self, ledger, options):
""" Execute the query
"""
tables, where_clause, where_params = ledger._query_get(options, domain=False, date_scope='strict_range')
ct_query = self.env['account.report']._get_query_currency_table(options)
query = f'''
SELECT
account_move_line.id,
account_move_line.name,
account_move_line.date,
account_move_line.currency_id,
account_move_line.amount_currency,
ROUND(account_move_line.debit * currency_table.rate, currency_table.precision) AS debit,
ROUND(account_move_line.credit * currency_table.rate, currency_table.precision) AS credit,
ROUND(account_move_line.balance * currency_table.rate, currency_table.precision) AS balance,
company.currency_id AS company_currency_id,
account.code AS account_code,
account.name AS account_name,
journal.name AS journal_name,
currency.name AS currency_name,
move.id AS move_id,
move.name AS move_name,
move.l10n_mx_edi_cfdi_uuid AS l10n_mx_edi_cfdi_uuid,
partner.vat AS partner_vat,
country.code AS country_code
FROM {tables}
LEFT JOIN account_move move ON move.id = account_move_line.move_id
LEFT JOIN {ct_query} ON currency_table.company_id = account_move_line.company_id
LEFT JOIN res_company company ON company.id = account_move_line.company_id
LEFT JOIN account_account account ON account.id = account_move_line.account_id
LEFT JOIN account_journal journal ON journal.id = account_move_line.journal_id
LEFT JOIN res_currency currency ON currency.id = account_move_line.currency_id
LEFT JOIN res_partner partner ON account_move_line.partner_id = partner.id
LEFT JOIN res_country country ON partner.country_id = country.id
WHERE {where_clause}
ORDER BY account_move_line.date, account_move_line.id
'''
self.env['account.move.line'].flush_model()
self.env.cr.execute(query, where_params)
result = self._cr.dictfetchall()
# accunt_name and journal_name will be translatable in case l10n_multilang is installed (and the cursor will then return a dict instead of string)
if self.env['account.journal']._fields['name'].translate:
result = [
{
**res,
'journal_name': res['journal_name'].get(self.env.user.lang, res['journal_name']['en_US']),
'account_name': res['account_name'].get(self.env.user.lang, res['account_name']['en_US']),
} for res in result
]
return result
def _get_move_export_data(self, accounts_results):
""" Parse db results in a structure feasible for xml report
"""
move_conversion_rate = {}
move_data = MoveExportData()
for line in accounts_results:
data = {
'line_label': textwrap.shorten(
line['journal_name'] + ((' - ' + line['name']) if line['name'] else ''),
width=200),
'account_name': line['account_name'],
'account_code': line['account_code'],
'credit': '%.2f' % line['credit'],
'debit': '%.2f' % line['debit'],
}
if line.get('l10n_mx_edi_cfdi_uuid'):
foreign_currency = line['currency_id'] and line['currency_id'] != line['company_currency_id']
amount_total = line['amount_currency'] if foreign_currency else line['balance']
if line['country_code'] != 'MX':
partner_rfc = 'XEXX010101000'
elif line['partner_vat']:
partner_rfc = line['partner_vat'].strip()
elif line['country_code'] in (False, 'MX'):
partner_rfc = 'XAXX010101000'
else:
partner_rfc = 'XEXX010101000'
currency_name = False
currency_conversion_rate = False
if foreign_currency:
# calculate conversion rate just once per move so we don't see
# rounding differences between lines
currency_name = line['currency_name']
currency_conversion_rate = move_conversion_rate.get(line['move_id'])
if not currency_conversion_rate:
amount_total_signed = line['balance']
if amount_total:
currency_conversion_rate = abs(amount_total_signed) / abs(amount_total)
else:
currency_conversion_rate = 1.0
currency_conversion_rate = '%.*f' % (5, currency_conversion_rate)
move_conversion_rate[line['move_id']] = currency_conversion_rate
data.update({
'uuid': line['l10n_mx_edi_cfdi_uuid'],
'partner_rfc': partner_rfc,
'currency_name': currency_name,
'currency_conversion_rate': currency_conversion_rate,
'amount_total': '%.2f' % amount_total,
})
move_data.append(line['date'], line['journal_name'], line['move_name'], data)
return move_data
def _get_moves_data(self):
""" Retrieve the moves data to be rendered with the template """
# Retrieve the data from the ledger itself, unfolding every group
ledger = self.env.ref('account_reports.general_ledger_report')
# Options ---------------------------------
# Ensure that the date range is enforced
options = self._options.copy()
# If the filter is on a date range, exclude initial balances
if options.get('date', {}).get('mode', '') == 'range':
options['general_ledger_strict_range'] = True
# Unfold all lines from the ledger
options['unfold_all'] = True
# We don't need all companies
options['companies'] = [{'name': self.env.company.name, 'id': self.env.company.id}]
# Retrieve --------------------------------
accounts_results = self._do_query(ledger, options)
# Group data for (year, month / move)
move_data = self._get_move_export_data(accounts_results)
# Sort the lines by name, to have a consistent order
for period, moves in move_data.items():
for move_key, lines in moves.items():
move_data[period][move_key] = sorted(lines, key=lambda x: x['line_label'])
return move_data
def _get_xml_data(self):
""" Gather the XML Polizas information and render the template.
This function is also called by tests. """
records = []
for period, moves in self._get_moves_data().items():
# Render the template
xml_content = self.env['ir.qweb']._render('l10n_mx_xml_polizas.xml_polizas', values={
'period': period,
'moves': moves,
'export_type': self.export_type,
'vat': self.env.company.vat,
'order_number': self.order_number,
'process_number': self.process_number,
})
# Validate against the XSD
with tools.file_open('l10n_mx_xml_polizas/data/xsd/1.3/PolizasPeriodo_1_3.xsd', 'rb') as xsd:
tools.xml_utils._check_with_xsd(xml_content, xsd)
records.append({
'year': period.year,
'month': period.month,
'filename': '%s%s%sPL.XML' % (self.env.company.vat, period.year, period.month),
'content': xml_content
})
return records
# ------------------------------
#
# Export
#
# ------------------------------
def export_xml(self):
""" Export the XML Polizas export for SAT, after some internal consistency check """
# Check VAT
vat = self.env.company.vat
if not vat:
action = self.env.ref('base.action_res_company_form')
raise RedirectWarning(_('Please define the VAT on your company.'),
action.id, _('Company Settings'))
elif not stdnum.mx.rfc.is_valid(vat):
raise UserError(_("The company's VAT is invalid for Mexico."))
# Retrieve the records
xml_records = self._get_xml_data()
# If there's no record, there's an error
if len(xml_records) == 0:
raise UserError(_('No records could be exported with current selection.'))
# If only one month was selected, return the XML
elif len(xml_records) == 1:
record = xml_records[0]
self.write({
'report_data': base64.b64encode(record['content'].encode()),
'report_filename': record['filename'],
'mimetype': 'application/xml',
})
# If there's more than one month, return a zipfile
else:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for record in xml_records:
zip_file.writestr(record['filename'], record['content'])
self.write({
'report_data': base64.b64encode(zip_buffer.getvalue()),
'report_filename': '%sPL.zip' % vat,
'mimetype': 'application/zip',
})
return {
'type': 'ir.actions.act_url',
'url': '/web/content/?' + url_encode({
'model': self._name,
'id': self.id,
'filename_field': 'report_filename',
'field': 'report_data',
'download': 'true'
}),
'target': 'new'
}