Files
enterprise-addons/l10n_lu_reports/models/account_sales_report.py
T

509 lines
26 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
from lxml import etree, objectify
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_compare
class L10n_LuEcSalesReportHandler(models.AbstractModel):
_name = 'l10n_lu.ec.sales.report.handler'
_inherit = ['account.ec.sales.report.handler']
_description = 'Luxembourgish EC Sales Report Custom Handler'
def _custom_options_initializer(self, report, options, previous_options):
"""
Add the invoice lines search domain that is specific to the country.
Typically, the taxes account.report.expression ids relative to the country for the triangular, sale of goods
or services.
:param dict options: Report options
:return dict: The modified options dictionary
"""
super()._init_core_custom_options(report, options, previous_options)
ec_operation_category = options.get('sales_report_taxes') or self._get_tax_tags_for_lux_sales_report()
# Change the names of the taxes to specific ones that are dependant to the tax type
ec_operation_category['operation_category'] = {
'goods': 'L',
'triangular': 'T',
'services': 'S',
}
options.update({'sales_report_taxes': ec_operation_category})
# Buttons
options.setdefault('buttons', []).append(
{'name': _('XML'), 'sequence': 30, 'action': 'open_report_export_wizard', 'branch_allowed': True}
)
def get_file_name(self, options):
''' 000000 X 20200101 T 120030 01
└> eCDF prefix └> X for XML └> date └> date/time separator └> time └> sequence num (we use ms)
'''
ecdf_prefix = self.env.company.ecdf_prefix
date_time = datetime.now().strftime('%Y%m%dT%H%M%S%f')[:-4]
filename = f'{ecdf_prefix}X{date_time}'
# `FileReference` element of exported XML must be the same as filename -> store in options
options['filename'] = filename
def get_file_data_lines(self, options):
report = self.env['account.report'].browse(options['report_id'])
lines = report._get_lines(report.get_options(options))[:-1] # Remove the total line
for i, line in enumerate(lines):
new_line = [j['no_format'] for j in line['columns']]
lines[i] = new_line
l_lines = []
t_lines = []
s_lines = []
l_sum = t_sum = s_sum = 0
for line in lines:
if line[2].startswith('L'):
l_sum += line[3]
l_lines.append(line)
elif line[2].startswith('T'):
t_sum += line[3]
t_lines.append(line)
else:
s_sum += line[3]
s_lines.append(line)
line[3] = ('%.2f' % line[3]).replace('.', ',')
return {
'l_lines': l_lines,
't_lines': t_lines,
's_lines': s_lines,
'l_sum': ('%.2f' % l_sum).replace('.', ','),
't_sum': ('%.2f' % t_sum).replace('.', ','),
's_sum': ('%.2f' % s_sum).replace('.', ','),
}
def get_report_data(self, options):
date_from = options['date'].get('date_from')
date_to = options['date'].get('date_to')
dt_from = fields.Date.from_string(date_from)
dt_to = fields.Date.from_string(date_to)
month = None
quarter = None
# dt_from is 1st day of months 1,4,7 or 10 and dt_to is last day of dt_from month+2
if dt_from.day == 1 and dt_from.month % 3 == 1 and dt_to == dt_from + relativedelta(day=31, month=dt_from.month + 2):
quarter = (dt_from.month - 1) // 3 + 1
# dt_from is 1st day & dt_to is last day of same month
elif dt_from.day == 1 and dt_from + relativedelta(day=31) == dt_to:
month = date_from[5:7]
else:
raise UserError(_('Check from/to dates. XML must cover 1 full month or 1 full quarter.'))
year = date_from[:4]
options['get_file_data'] = True
xml_data = self.get_file_data_lines(options)
return xml_data, month, quarter, year
@api.model
def export_to_xml(self, options):
# Check
company = self.env.company
errors = []
self.env['l10n_lu.report.handler']._validate_ecdf_prefix()
company_vat = company.partner_id.vat
if not company_vat:
errors.append(_('VAT'))
matr_number = company.matr_number
if not matr_number:
errors.append(_('Matr Number'))
if errors:
raise UserError(_('The following must be set on your company:\n- %s', ('\n- '.join(errors))))
rcs_number = company.company_registry or 'NE'
self.get_file_name(options)
file_ref = options['filename']
company_vat = company_vat.replace(' ', '').upper()[2:]
xml_data, month, quarter, year = self.get_report_data(options)
xml_data.update({
"file_ref": file_ref,
"matr_number": matr_number,
"rcs_number": rcs_number,
"company_vat": company_vat,
"year": year,
"period": month or quarter,
"type_labes": month and ['TVA_LICM', 'TVA_PSIM'] or ['TVA_LICT', 'TVA_PSIT'],
})
rendered_content = self.env['ir.qweb']._render('l10n_lu_reports.EcSalesLuXMLReport', xml_data)
return b"<?xml version='1.0' encoding='utf-8'?>" + rendered_content.encode()
def get_xml_2_0_report_values(self, options, comparison_files):
"""
Returns the formatted forms for the LU VAT recapitulative statements (Intracommunity exchange of goods and
services).
This comprises the recapitulative statement of IC supplies of goods (if selected in the options) and
the recapitulative statement of IC supplies of services (if selected in the options).
Both declarations comprise corrections tables, which can be used to correct the data reported in previous
declarations.
These tables are filled in by comparing what would be reported at present time with the given comparison
declarations.
(IC supplies of goods (monthly): https://ecdf-developer.b2g.etat.lu/ecdf/forms/popup/TVA_LICM_TYPE/2020M01/en
/1/preview
IC supplies of services (monthly): https://ecdf-developer.b2g.etat.lu/ecdf/forms/popup/TVA_PSIM_TYPE/2020M01
/en/1/preview)
:param comparison_files: past declarations to check and correct if needed
"""
xml_data, month, quarter, year = self.get_report_data(options)
# Format the values for the export
intra_codes = []
for c in options.get('ec_tax_filter_selection', {}):
if c['selected']:
intra_codes.append(options.get('sales_report_taxes', {}).get('operation_category', {})[c['id']])
if not intra_codes:
intra_codes = ['L', 'T', 'S']
corr, comped_decl = self.get_correction_data(options, comparison_files)
forms = self.format_export_values(intra_codes, xml_data, corr, comped_decl, month, quarter, year)
for form in forms:
form.update({
'year': year,
'period': int(month or quarter),
'currency': self.env.company.currency_id.name,
'model': '1',
})
return forms, year, month and 'M' + str(month) or quarter and 'Q' + str(quarter), ''.join(intra_codes)
def open_report_export_wizard(self, options):
""" Creates a new export wizard for this report."""
new_context = self.env.context.copy()
new_context['report_generation_options'] = options
return {
'type': 'ir.actions.act_window',
'name': _('Export'),
'view_mode': 'form',
'res_model': 'l10n_lu.generate.vat.intra.report',
'target': 'new',
'views': [[self.env.ref('l10n_lu_reports.view_l10n_lu_generate_vat_intra_report').id, 'form']],
'context': new_context,
}
def format_export_values(self, intrastat_codes, xml_data, corrections, compared_declarations, month, quarter, year):
"""
Returns the formatted forms for the LU VAT recapitulative statements (Intracommunity exchange of goods and services).
:param intrastat_codes: the codes of the tables to fill in ('L': goods, 'T': triangular operations, 'S': services)
:param xml_data: the data for the report of the selected period (from get_file_data_lines())
:para corrections: corrections for previous declarations to include in the report
:param s: tuple (year, month/quarter, declaration_type) describing the previous declarations that are to be corrected
:param month: the month of the declared period (if a monthly period is declared)
:param quarter: the quarter of the declared period (if a quarterly period is declared)
:param year: the year of the declared period
"""
def _get_decl_table(lines, total, mapping):
table = [{
mapping['country']: {'field_type': 'char', 'value': line[0]},
mapping['vat']: {'field_type': 'char', 'value': line[1]},
mapping['amount']: {'field_type': 'number', 'value': line[3]},
} for line in lines]
tot = {mapping['total']: {'field_type': 'number', 'value': total}}
return table, tot
def _get_corrective_table(corr, is_triangular, language):
corr_table = []
for correction_time, line in corr.items():
period_field = '12' if correction_time[0][-1] == 'T' else '18'
for vat_info, amount in line.items():
corr_table.append({
'09': {'field_type': 'char', 'value': vat_info[0]}, # Partner Country
'10': {'field_type': 'char', 'value': vat_info[1]}, # Partner VAT number
'11': {'field_type': 'number', 'value': correction_time[1]}, # Year
period_field: {'field_type': 'number', 'value': correction_time[2]}, # Period
'14': {'field_type': 'float', 'value': amount},
})
if is_triangular:
corr_table[-1]['15'] = {'field_type': 'char', 'value': language == 'DE' and 'Ja' or language == 'FR' and 'Oui' or 'Yes'}
return corr_table
forms = []
language = self.env.context['report_generation_options'].get('language')
# The correction of reports having the same declaring period isn't allowed,
# and the correction of reports having later declaring periods doesn't make sense
for _dummy, ctype, cyear, cperiod in compared_declarations:
# convert quarters to months to compare; because any overlap has to be avoided,
# take last month of the quarter of the compared declarations,
# and the first of the present declaration
cperiodmonth = int(cperiod) * 3 if ctype[-1] == 'T' else int(cperiod)
dperiod = int(month) if month else quarter * 3 - 2
if int(cyear) > int(year) or cperiodmonth >= dperiod and int(cyear) == int(year):
raise ValidationError(_("Some problems with the comparison declarations occurred. "
"The comparison declarations must refer to earlier periods."))
# L & T
if ('L' in intrastat_codes or 'T' in intrastat_codes) and (xml_data['l_lines'] or xml_data['t_lines']):
declaration_type = month and 'TVA_LICM' or 'TVA_LICT'
form = {'declaration_type': declaration_type, 'tables': [], 'field_values': {}}
# L declaration
mapping = {'total': '04', 'country': '01', 'vat': '02', 'amount': '03'}
l_table, l_tot = _get_decl_table(xml_data['l_lines'], xml_data['l_sum'], mapping)
if xml_data['l_lines']:
form['tables'].append(l_table)
form['field_values'].update(l_tot)
# T declaration
mapping = {'total': '08', 'country': '05', 'vat': '06', 'amount': '07'}
t_table, t_tot = _get_decl_table(xml_data['t_lines'], xml_data['t_sum'], mapping)
if xml_data['t_lines']:
form['tables'].append(t_table)
form['field_values'].update(t_tot)
# Corrections
total_corrections = 0.00
if corrections:
corr_tables = []
if 'L' in intrastat_codes:
corr_tables.extend(_get_corrective_table(corrections['l_lines'], is_triangular=False, language=language))
if 'T' in intrastat_codes:
corr_tables.extend(_get_corrective_table(corrections['t_lines'], is_triangular=True, language=language))
if corr_tables:
form['tables'].append(corr_tables)
total_corrections = corrections['l_sum'] * int('L' in intrastat_codes) + corrections['t_sum'] * int('T' in intrastat_codes)
form['field_values']['16'] = {'field_type': 'float', 'value': total_corrections}
forms.append(form)
# S
if 'S' in intrastat_codes and xml_data['s_lines']:
declaration_type = month and 'TVA_PSIM' or 'TVA_PSIT'
form = {'declaration_type': declaration_type, 'tables': [], 'field_values': {}}
mapping = {'total': '04', 'country': '01', 'vat': '02', 'amount': '03'}
s_table, s_tot = _get_decl_table(xml_data['s_lines'], xml_data['s_sum'], mapping)
if xml_data['s_lines']:
form['tables'].append(s_table)
form['field_values'].update(s_tot)
# Corrections
if corrections:
s_corr_table = _get_corrective_table(corrections['s_lines'], is_triangular=False, language=language)
total_corrections = corrections['s_sum'] if corrections else 0.00
form['field_values']['16'] = {'field_type': 'float', 'value': corrections['s_sum']}
if s_corr_table:
form['tables'].append(s_corr_table)
forms.append(form)
return forms
def get_correction_data(self, options, comparison_files):
"""
Compares the data from old declarations to the data that would be reported at this time.
:param comparison_files(strings): the eCDF-formatted previous declarations to check and correct
:return
corrections: a dictionary
- x_sum: <sum of all corrections for l_lines>
- x_lines: {(decl_type, decl_year, decl_period): {(acquirer_country, acquirer_vat): <correction_amount>, ..}, ..}
for x in l, t, s
original_declarations: a list of tuples (year, month/quarter, declaration_type)
describing the relevant declarations contained in the comparison files
"""
attached_declarations = []
for name, dec in comparison_files:
try:
attached_declarations.append((name, self.get_data_from_xml(dec, self.env.company.matr_number)))
except ValidationError as err:
raise ValidationError(_("Error in file %(file_name)s: %(error)s", file_name=name, error=str(err)))
summarized_data, original_declarations = self.summarize_data(attached_declarations)
corrections = self.compare_declarations(summarized_data, options)
return corrections, original_declarations
def get_data_from_xml(self, xml_file_string, company_matr):
"""
Gets the EC Sales declarations data from an ecdf-compliant formatted xml declaration,
for the company with the indicated Matr. number.
:param xml_file_string: the file to be parsed
:param company_matr
:return: a data dictionary with:
- 'original': lines originally declared in the declaration
- 'corrective': lines that correct a previous declaration
- 'declared': original declarations
Each line a dictionary with keys: type, year, period, acquirer, triangular
"""
fields_map = {
'01': ('acquirer_country',),
'02': ('acquirer_vat',),
'03': ('amount',),
'05': ('acquirer_country',),
'06': ('acquirer_vat',),
'07': ('amount', 'triangular'),
'09': ('acquirer_country',),
'10': ('acquirer_vat',),
'11': ('year',),
'12': ('quarterly', 'period'),
'14': ('amount',),
'15': ('triangular',),
'18': ('monthly', 'period'),
}
prfx = '{http://www.ctie.etat.lu/2011/ecdf}'
try:
root = objectify.fromstring(xml_file_string)
except etree.XMLSyntaxError:
raise ValidationError(_("The provided comparison file is not a properly formatted XML."))
data = {'original': [], 'corrective': [], 'declared': []}
try:
# Element Declarer
declarations_elem = [d for d in root.iterchildren(tag=prfx + 'Declarations')]
declarer = [declarer for d in declarations_elem for declarer in d.iterchildren(tag=prfx + 'Declarer')]
# Filter for company with the right company Matr.nb and of IC type
declarations = []
for decl in declarer:
if decl.MatrNbr.text == company_matr:
for d in decl.iterchildren(tag=prfx + 'Declaration'):
if d.get('type') in ('TVA_LICT', 'TVA_LICM', 'TVA_PSIT', 'TVA_PSIM'):
declarations.append(d)
for decl in declarations:
year = decl.Year.text
period = decl.Period.text
dtype = decl.attrib['type']
# Don't care about the totals, only values in tables
tables = [table for table in decl.FormData.iterchildren(tag=prfx + 'Table')]
for line in [line for table in tables for line in table.iterchildren(tag=prfx + 'Line')]:
line_data = {}
for field in line.iterchildren(prfx + 'TextField', prfx + 'NumericField'):
if field.attrib['id'] in fields_map:
for field_name in fields_map[field.attrib['id']]:
line_data[field_name] = field.text
corrective = 'year' in line_data
line_data['triangular'] = bool(line_data.get('triangular', False))
if not corrective:
line_data['year'] = year
line_data['period'] = period
line_data['type'] = dtype
else:
line_data['type'] = dtype[:-1] + ('T' if line_data.get('quarterly', False) else 'M')
for field_name in {'quarterly', 'monthly'}:
line_data.pop(field_name, None)
if corrective:
data['corrective'].append(line_data)
else:
data['original'].append(line_data)
data['declared'].append({'year': year, 'period': period, 'type': dtype})
except:
raise ValidationError(_("The provided comparison file is not a proper eCDF XML declaration!"))
# if no interesting data has been found in the declarations, then probably the user made a mistake
if not data['corrective'] and not data['original'] and not data['declared']:
raise ValidationError(_("There are no Intracommunity VAT declarations for the declaring company in the provided file!"))
return data
def summarize_data(self, declarations):
"""
Summarizes the declaration data from multiple declarations.
Form: {(type, year, period): {'s_lines': {(acquirer_country: x, acquirer_vat: y): <amount>, ..}, 't_lines': {..}, 's_lines': {..}}, ..}
"""
original_declarations = [(d['type'], d['year'], d['period']) for decl in declarations for d in decl[1]['declared']]
summarized_data = {key: {'s_lines': {}, 't_lines': {}, 'l_lines': {}} for key in original_declarations}
# Iterate over all lines whose original declaration is present in the given declarations;
# this allows, for example, correcting just the previous declaration by comparing with it,
# without taking into consideration the corrections that it made to previous declarations
for line in [ln for data in declarations for ln in (data[1]['original'] + data[1]['corrective'])]:
line_decl = (line['type'], line['year'], line['period'])
if line_decl in original_declarations:
line_type = 's_lines' if line['type'][4] == 'P' else line['triangular'] and 't_lines' or 'l_lines'
acq = (line['acquirer_country'], line['acquirer_vat'])
summarized_data[line_decl][line_type][acq] = summarized_data[line_decl][line_type].get(acq, 0.00) + float(line['amount'].replace(',', '.'))
# Empty declarations (no data for the declared period) need to be considered too
for data in original_declarations:
summarized_data.setdefault(data, {'s_lines': {}, 'l_lines': {}, 't_lines': {}})
# Need the filename as well to be able to raise precise error messages
original_declarations = [(decl[0], d['type'], d['year'], d['period']) for decl in declarations for d in decl[1]['declared']]
return summarized_data, original_declarations
def compare_declarations(self, summarized_data, options):
"""
Compares the data from summarized data to the data that would be reported at this time.
:param summarized_data: data in the form coming from summarize_data
:return: dictionary:
- x_sum: <sum of all corrections for l_lines>
- x_lines: {(decl_type, decl_year, decl_period): {(acquirer_country, acquirer_vat): <correction_amount>, ..}, ..}
for x in l, t, s
"""
def _compare_lines(old, new):
diff = {}
for k in new:
corr = float(new[k].replace(',', '.')) - old.get(k, 0.00)
if float_compare(corr, 0.0, 2) != 0:
diff[k] = corr
for k in old:
if k not in new:
if float_compare(old[k], 0.0, 2) != 0:
diff[k] = -old[k]
return diff
corrections = {'l_lines': {}, 't_lines': {}, 's_lines': {}}
for key in summarized_data: # key: (declaration_type, year, period)
decl_type, decl_year, decl_period = key
# Compute declaration dates
decl_day_from = 1
decl_month_from = int(decl_period) if decl_type[-1] == 'M' else 1 + 3 * (int(decl_period) - 1)
decl_date_from = datetime(int(decl_year), decl_month_from, decl_day_from)
delta = relativedelta(months=1, days=-1) if decl_type[-1] == 'M' else relativedelta(months=3, days=-1)
decl_date_to = decl_date_from + delta
options['date'] = {'mode': 'range', 'date_from': decl_date_from, 'date_to': decl_date_to}
# Get the actualised data for the examined period
options.update({'get_xml_data': True, 'filter_unfold_all': True})
new_lines = self.get_file_data_lines(options)
new_data = {
ln_type: {
(k[0], k[1]): k[3] for k in decl
} for ln_type, decl in new_lines.items() if ln_type.endswith('lines')
}
for k in new_data:
if (k[0] in ('l', 't') and key[0].startswith('TVA_L')) or (k[0] == 's' and key[0].startswith('TVA_P')):
corrections[k][key] = _compare_lines(summarized_data[key].get(k, {}), new_data[k])
else:
corrections[k][key] = {}
# compute the sums of the corrections for each line type
correction_sums = {}
for k in corrections:
correction_sums[k.replace('lines', 'sum')] = sum([sum(corrections[k][key].values()) for key in corrections[k]])
corrections.update(correction_sums)
return corrections
def _get_tax_tags_for_lux_sales_report(self):
return {
'goods': tuple(self.env.ref('l10n_lu.account_tax_report_line_1b_1_intra_community_goods_pi_vat_tag')._get_matching_tags().ids),
'triangular': tuple(self.env.ref('l10n_lu.account_tax_report_line_1b_6_a_subsequent_to_intra_community_tag')._get_matching_tags().ids),
'services': tuple(self.env.ref('l10n_lu.account_tax_report_line_1b_6_b1_non_exempt_customer_vat_tag')._get_matching_tags().ids),
}
class L10n_LuStoredIntraReport(models.Model):
_name = 'l10n_lu.stored.intra.report'
_description = "Wrapper for an attachment, adds the financial report data"
_rec_name = "display_name"
attachment_bin = fields.Binary()
year = fields.Char(required=True)
period = fields.Char(required=True)
codes = fields.Selection([
('LT', 'Supply of goods and supply of goods made in the context of triangular operations'),
('S', 'Supply of services'),
('LTS', 'Supply of goods (normal/in the context of triangular operations) and supply of services')
])
company_id = fields.Many2one(comodel_name='res.company', default=lambda self: self.env.company.id)
name = fields.Char(required=True)
@api.depends('year', 'period', 'codes', 'name')
def _compute_display_name(self):
for r in self:
r.display_name = f"{r.year}/{r.period}/{r.codes} : {r.name}"
def action_download_stored_report(self):
self.ensure_one()
return {
"type": "ir.actions.act_url",
"url": f"/web/content/l10n_lu.stored.intra.report/{self.id}/attachment_bin?download=true",
"target": "download",
}