Files
enterprise-addons/l10n_be_reports/models/account_report.py
T

372 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import calendar
import re
import stdnum
from markupsafe import Markup
from odoo import models, _
from odoo.exceptions import RedirectWarning, UserError
def _raw_phonenumber(phonenumber):
return re.sub("[^+0-9]", "", phonenumber)[:20]
def _split_vat_number_and_country_code(vat_number):
"""
Even with base_vat, the vat number doesn't necessarily starts
with the country code
We should make sure the vat is set with the country code
to avoid submitting a declaration with a wrong vat number
"""
vat_number = vat_number.replace(' ', '').upper()
try:
int(vat_number[:2])
country_code = None
except ValueError:
country_code = vat_number[:2]
vat_number = vat_number[2:]
return vat_number, country_code
def _get_xml_export_representative_node(report):
""" The <Representative> node is common to XML exports made for VAT Listing, VAT Intra,
and tax declaration. It is used in case the company isn't submitting its report directly,
but through an external accountant.
:return: The string containing the complete <Representative> node or an empty string,
in case no representative has been configured.
"""
representative = report.env.company.account_representative_id
if representative:
vat_no, country_from_vat = _split_vat_number_and_country_code(representative.vat or "")
country = report.env['res.country'].search([('code', '=', country_from_vat)], limit=1)
phone = representative.phone or ''
node_values = {
'vat': stdnum.get_cc_module('be', 'vat').compact(vat_no), # Sanitize VAT number
'name': representative.name,
'street': "%s %s" % (representative.street or "", representative.street2 or ""),
'zip': representative.zip,
'city': representative.city,
'country_code': (country or representative.country_id).code,
'email': representative.email,
'phone': _raw_phonenumber(phone)
}
missing_fields = [k for k, v in node_values.items() if not v or v == ' ']
if missing_fields:
message = _('Some fields required for the export are missing. Please specify them.')
action = {
'name': _("Company: %s", representative.name),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'res.partner',
'views': [[False, 'form']],
'target': 'new',
'res_id': representative.id,
'context': {'create': False},
}
button_text = _('Specify')
additional_context = {'required_fields': missing_fields}
raise RedirectWarning(message, action, button_text, additional_context)
return Markup("""<ns2:Representative>
<RepresentativeID identificationType="NVAT" issuedBy="%(country_code)s">%(vat)s</RepresentativeID>
<Name>%(name)s</Name>
<Street>%(street)s</Street>
<PostCode>%(zip)s</PostCode>
<City>%(city)s</City>
<CountryCode>%(country_code)s</CountryCode>
<EmailAddress>%(email)s</EmailAddress>
<Phone>%(phone)s</Phone>
</ns2:Representative>""") % node_values
return Markup()
class L10n_BeTaxReportHandler(models.AbstractModel):
_name = 'l10n_be.tax.report.handler'
_inherit = ['account.tax.report.handler']
_description = 'Belgian Tax Report Custom Handler'
def _custom_options_initializer(self, report, options, previous_options):
super()._custom_options_initializer(report, options, previous_options=previous_options)
# Export options set by the return submission wizard ; we need them here so that they're not reset in
# the pdf generation, when calling _get_options()
options.update({
'l10n_be_closing_vat_return': previous_options.get('l10n_be_closing_vat_return'),
'closing_entry': previous_options.get('closing_entry'),
'ask_restitution': previous_options.get('ask_restitution'),
'client_nihil': previous_options.get('client_nihil'),
})
options['custom_display_config']['pdf_export'] = {'pdf_export_filters': 'l10n_be_reports.pdf_export_filters'}
def open_account_report_sales(self, options):
action = self.env['ir.actions.actions']._for_xml_id('account_reports.action_account_report_sales')
action['params'] = {
'options': options,
'ignore_session': True,
}
return action
def export_tax_report_to_xml(self, options):
report = self.env['account.report'].browse(options['report_id'])
vat_no, country_from_vat = _split_vat_number_and_country_code(report.get_vat_for_export(options))
sender_company = report._get_sender_company_for_export(options)
default_address = sender_company.partner_id.address_get()
address = self.env['res.partner'].browse(default_address.get("default")) or sender_company.partner_id
if not address.email:
raise UserError(_('No email address associated with company %s.', sender_company.name))
if not address.phone:
raise UserError(_('No phone associated with company %s.', sender_company.name))
# Compute xml
issued_by = vat_no
dt_from = options['date'].get('date_from')
dt_to = options['date'].get('date_to')
send_ref = str(sender_company.partner_id.id) + str(dt_from[5:7]) + str(dt_to[:4])
starting_month = dt_from[5:7]
ending_month = dt_to[5:7]
quarter = str(((int(starting_month) - 1) // 3) + 1)
date_from = dt_from[0:7] + '-01'
date_to = dt_to[0:7] + '-' + str(calendar.monthrange(int(dt_to[0:4]), int(ending_month))[1])
deduction_text = self._get_deduction_text(options)
complete_vat = (country_from_vat or (address.country_id and address.country_id.code or "")) + vat_no
file_data = {
'issued_by': issued_by,
'vat_no': complete_vat,
'only_vat': vat_no,
# Company name can contain only latin characters
'company_name': sender_company.name,
'address': "%s %s" % (address.street or "", address.street2 or ""),
'post_code': address.zip or "",
'city': address.city or "",
'country_code': address.country_id and address.country_id.code or "",
'email': address.email or "",
'phone': _raw_phonenumber(address.phone),
'send_ref': send_ref,
'quarter': quarter,
'month': starting_month,
'year': str(dt_to[:4]),
'client_nihil': options.get('client_nihil', False) and 'YES' or 'NO',
'ask_restitution': options.get('ask_restitution', False) and 'YES' or 'NO',
'comment': options.get('comment'),
'prorata_deduction': deduction_text,
'representative_node': _get_xml_export_representative_node(report),
'rectification_ref': options.get('rectification_ref'),
}
rslt = Markup(f"""<?xml version="1.0"?>
<ns2:VATConsignment xmlns="http://www.minfin.fgov.be/InputCommon" xmlns:ns2="http://www.minfin.fgov.be/VATConsignment" VATDeclarationsNbr="1">
%(representative_node)s
<ns2:VATDeclaration SequenceNumber="1" DeclarantReference="%(send_ref)s">""") % file_data
if file_data.get('rectification_ref'):
rslt += Markup("""
<ns2:ReplacedVATDeclaration>%(rectification_ref)s</ns2:ReplacedVATDeclaration>
""") % file_data
rslt += Markup(f"""<ns2:Declarant>
<VATNumber xmlns="http://www.minfin.fgov.be/InputCommon">%(only_vat)s</VATNumber>
<Name>%(company_name)s</Name>
<Street>%(address)s</Street>
<PostCode>%(post_code)s</PostCode>
<City>%(city)s</City>
<CountryCode>%(country_code)s</CountryCode>
<EmailAddress>%(email)s</EmailAddress>
<Phone>%(phone)s</Phone>
</ns2:Declarant>
<ns2:Period>
{"<ns2:Quarter>%(quarter)s</ns2:Quarter>" if starting_month != ending_month else "<ns2:Month>%(month)s</ns2:Month>"}
<ns2:Year>%(year)s</ns2:Year>
</ns2:Period>
%(prorata_deduction)s
<ns2:Data>""") % file_data
grids_list = []
currency_id = self.env.company.currency_id
options = report.get_options({'no_format': True, 'date': {'date_from': date_from, 'date_to': date_to}, 'filter_unfold_all': True, 'export_mode': 'file'})
lines = report._get_lines(options)
# Create a mapping between report line ids and actual grid names
non_compound_rep_lines = report.line_ids.expression_ids.filtered(
lambda x: x.formula not in {'48s44', '48s46L', '48s46T', '-46L', '-46T'})
lines_grids_map = {
expr.report_line_id.id: expr.formula.split('.')[0].replace('c', '') for expr in non_compound_rep_lines
}
lines_grids_map[self.env.ref('l10n_be.tax_report_title_operations_sortie_46').id] = '46'
lines_grids_map[self.env.ref('l10n_be.tax_report_title_operations_sortie_48').id] = '48'
lines_grids_map[self.env.ref('l10n_be.tax_report_line_71').id] = '71'
lines_grids_map[self.env.ref('l10n_be.tax_report_line_72').id] = '72'
colname_to_idx = {col['expression_label']: idx for idx, col in enumerate(options.get('columns', []))}
# Iterate on the report lines, using this mapping
for line in lines:
model, line_id = report._parse_line_id(line['id'])[-1][1:]
if (
model == 'account.report.line'
and line_id in lines_grids_map
and not currency_id.is_zero(line['columns'][colname_to_idx['balance']]['no_format'])
):
grids_list.append((lines_grids_map[line_id].lstrip('-'),
line['columns'][colname_to_idx['balance']]['no_format']))
# We are ignoring all grids that have 0 as values, but the belgian government always require a value at
# least in either the grid 71 or 72. So in the case where both are set to 0, we are adding the grid 71 in the
# xml with 0 as a value.
if len([item for item in grids_list if item[0] == '71' or item[0] == '72']) == 0:
grids_list.append(('71', 0))
# Government expects a value also in grid '00' in case of vat_unit
if options.get('tax_unit') and options.get('tax_unit') != 'company_only' and len([item for item in grids_list if item[0] == '00']) == 0:
grids_list.append(('00', 0))
grids_list = sorted(grids_list, key=lambda a: a[0])
for code, amount in grids_list:
# These tags are carried over the next period, they should not appear in the xml if amount is below 0
if code not in ('81', '82', '83', '86', '87', '88') or currency_id.compare_amounts(amount, 0) == 1:
grid_amount_data = {
'code': code,
'amount': '%.2f' % amount,
}
rslt += Markup("""
<ns2:Amount GridNumber="%(code)s">%(amount)s</ns2:Amount>""") % grid_amount_data
rslt += Markup("""
</ns2:Data>
<ns2:ClientListingNihil>%(client_nihil)s</ns2:ClientListingNihil>
<ns2:Ask Restitution="%(ask_restitution)s"/>
""") % file_data
if file_data['comment']:
rslt += Markup("""
<ns2:Comment>%(comment)s</ns2:Comment>
""") % file_data
rslt += Markup("""
</ns2:VATDeclaration>
</ns2:VATConsignment>
""")
return {
'file_name': report.get_default_report_filename(options, 'xml'),
'file_content': rslt.encode(),
'file_type': 'xml',
}
def _get_deduction_text(self, options):
# To Override to include deductions in XML
deduction_dict = options.get('prorata_deduction', {})
if not deduction_dict.get('prorata'):
return ''
return self.env['ir.qweb']._render('l10n_be_reports.vat_export_prorata', deduction_dict)
def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
def val(expr_totals, code):
return expr_totals[expr_map[code]]['value']
def sum_vals(expr_totals, *codes):
return sum(val(expr_totals, c) for c in codes)
def evaluate(check):
return all(check(expr_totals) for expr_totals in all_column_groups_expression_totals.values())
super()._customize_warnings(report, options, all_column_groups_expression_totals, warnings)
expr_map = {
line.code: line.expression_ids.filtered(lambda x: x.label == 'balance')
for line in report.line_ids
if line.code
}
currency = self.env.company.currency_id
rounding_tolerance = 62.0
checks = [
(
# Code 13
self.env._("Negative amounts not allowed"),
lambda e: all(
currency.compare_amounts(val(e, code), 0.0) >= 0
for code, expr in expr_map.items()
if code not in ('c81', 'c82', 'c83', 'c86', 'c87', 'c88') # carryover lines
),
),
(
# Code C
self.env._('[55] > 0 if [86] > 0 or [88] > 0'),
lambda e: currency.compare_amounts(val(e, 'c55'), 0.0) > 0
if currency.compare_amounts(val(e, 'c86'), 0.0) > 0 or currency.compare_amounts(val(e, 'c88'), 0.0) > 0 else True,
),
(
# Code D
self.env._('[56] + [57] > 0 if [87] > 0'),
lambda e: currency.round(sum_vals(e, 'c56', 'c57')) > 0 if val(e, 'c87') > 0 else True,
),
(
# Code O
'[01] * 6% + [02] * 12% + [03] * 21% = [54] ± 62',
lambda e: currency.compare_amounts(rounding_tolerance, abs(
val(e, 'c01') * 0.06 +
val(e, 'c02') * 0.12 +
val(e, 'c03') * 0.21 -
val(e, 'c54')
)) >= 0,
),
(
# Code P
'([84] + [86] + [88]) * 21% >= [55] - 62',
lambda e: currency.compare_amounts(sum_vals(e, 'c84', 'c86', 'c88') * 0.21, val(e, 'c55') - rounding_tolerance) >= 0,
),
(
# Code Q
'([85] + [87]) * 21% >= ([56] + [57]) - 62',
lambda e: currency.compare_amounts(sum_vals(e, 'c85', 'c87') * 0.21, sum_vals(e, 'c56', 'c57') - rounding_tolerance) >= 0,
),
(
# Code S
'([81] + [82] + [83] + [84] + [85]) * 50% >= [59]',
lambda e: currency.compare_amounts(sum_vals(e, 'c81', 'c82', 'c83', 'c84', 'c85') * 0.5, val(e, 'c59')) >= 0,
),
(
# Code T
'[85] * 21% >= [63] - 62',
lambda e: currency.compare_amounts(val(e, 'c85') * 0.21, val(e, 'c63') - rounding_tolerance) >= 0,
),
(
# Code U
'[49] * 21% >= [64] - 62',
lambda e: currency.compare_amounts(val(e, 'c49') * 0.21, val(e, 'c64') - rounding_tolerance) >= 0,
),
(
# Code AC
self.env._('[88] < ([81] + [82] + [83] + [84]) * 100 if [88] > 99,999'),
lambda e: currency.compare_amounts(val(e, 'c88'), sum_vals(e, 'c81', 'c82', 'c83', 'c84') * 100) == -1
if currency.compare_amounts(val(e, 'c88'), 99_999) > 0 else True,
),
(
# Code AD
self.env._('[44] < ([00] + [01] + [02] + [03] + [45] + [46] + [47] + [48] + [49]) * 200 if [44] > 99,999'),
lambda e: currency.compare_amounts(val(e, 'c44'), sum_vals(e, 'c00', 'c01', 'c02', 'c03', 'c45', 'c46', 'c47', 'c48', 'c49') * 200) == -1
if currency.compare_amounts(val(e, 'c44'), 99_999) > 0 else True,
),
]
failed_controls = [name for name, check in checks if not evaluate(check)]
if failed_controls:
warnings['l10n_be_reports.tax_report_warning_checks'] = {
'failed_controls': failed_controls,
'alert_type': 'danger',
}