Files

364 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
import re
from datetime import date
from collections import defaultdict
from lxml import etree
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.misc import file_path
_logger = logging.getLogger(__name__)
# Sources:
# - Technical Doc https://finances.belgium.be/fr/E-services/Belcotaxonweb/documentation-technique
# - "Avis aux débiteurs" https://finances.belgium.be/fr/entreprises/personnel_et_remuneration/avis_aux_debiteurs#q2
COUNTRY_CODES = {
'AD': '00102', 'AE': '00260', 'AF': '00251', 'AG': '00403', 'AI': '00490', 'AL': '00101', 'AM': '00249',
'AO': '00341', 'AR': '00511', 'AS': '00690', 'AT': '00105', 'AU': '00611', 'AZ': '00250', 'BA': '00149',
'BB': '00423', 'BD': '00237', 'BE': '00000', 'BF': '00308', 'BG': '00106', 'BH': '00268', 'BI': '00303',
'BJ': '00310', 'BM': '00485', 'BN': '00224', 'BO': '00512', 'BR': '00513', 'BS': '00425', 'BT': '00223',
'BW': '00302', 'BY': '00142', 'BZ': '00430', 'CA': '00401', 'CD': '00306', 'CF': '00305', 'CG': '00307',
'CH': '00127', 'CI': '00309', 'CK': '00687', 'CL': '00514', 'CM': '00304', 'CN': '00218', 'CO': '00515',
'CR': '00411', 'CU': '00412', 'CV': '00339', 'CY': '00107', 'CZ': '00140', 'DE': '00103', 'DJ': '00345',
'DK': '00108', 'DM': '00480', 'DO': '00427', 'DZ': '00351', 'EC': '00516', 'EE': '00136', 'EG': '00352',
'EH': '00388', 'ER': '00349', 'ES': '00109', 'ET': '00311', 'FI': '00110', 'FJ': '00617', 'FK': '00580',
'FM': '00602', 'FR': '00111', 'GA': '00312', 'GB': '00112', 'GD': '00426', 'GE': '00253', 'GF': '00581',
'GH': '00314', 'GI': '00180', 'GL': '00498', 'GM': '00313', 'GN': '00315', 'GP': '00496', 'GQ': '00337',
'GR': '00114', 'GT': '00413', 'GU': '00681', 'GW': '00338', 'GY': '00521', 'HK': '00234', 'HN': '00414',
'HR': '00146', 'HT': '00419', 'HU': '00115', 'ID': '00208', 'IE': '00116', 'IL': '00256', 'IN': '00207',
'IQ': '00254', 'IR': '00255', 'IS': '00117', 'IT': '00128', 'JM': '00415', 'JO': '00257', 'JP': '00209',
'KE': '00336', 'KG': '00226', 'KH': '00216', 'KI': '00622', 'KM': '00343', 'KN': '00431', 'KP': '00219',
'KR': '00206', 'KW': '00264', 'KY': '00492', 'KZ': '00225', 'LA': '00210', 'LB': '00258', 'LC': '00428',
'LI': '00118', 'LK': '00203', 'LR': '00318', 'LS': '00301', 'LT': '00137', 'LU': '00113', 'LV': '00135',
'LY': '00353', 'MA': '00354', 'MC': '00120', 'MD': '00144', 'ME': '00151', 'MG': '00324', 'MH': '00603',
'MK': '00148', 'ML': '00319', 'MM': '00201', 'MN': '00221', 'MO': '00281', 'MQ': '00497', 'MR': '00355',
'MS': '00493', 'MT': '00119', 'MU': '00317', 'MV': '00222', 'MW': '00358', 'MX': '00416', 'MY': '00212',
'MZ': '00340', 'NA': '00384', 'NC': '00683', 'NE': '00321', 'NG': '00322', 'NI': '00417', 'NL': '00129',
'NO': '00121', 'NP': '00213', 'NR': '00615', 'NU': '00604', 'NZ': '00613', 'OM': '00266', 'PA': '00418',
'PE': '00518', 'PF': '00684', 'PG': '00619', 'PH': '00214', 'PK': '00259', 'PL': '00122', 'PM': '00495',
'PN': '00692', 'PR': '00487', 'PS': '00271', 'PT': '00123', 'PW': '00679', 'PY': '00517', 'QA': '00267',
'RE': '00387', 'RO': '00124', 'RS': '00152', 'RU': '00145', 'RW': '00327', 'SA': '00252', 'SB': '00623',
'SC': '00342', 'SD': '00356', 'SE': '00126', 'SG': '00205', 'SH': '00389', 'SI': '00147', 'SK': '00141',
'SL': '00328', 'SM': '00125', 'SN': '00320', 'SO': '00329', 'SR': '00522', 'SS': '00365', 'SV': '00421',
'SY': '00261', 'SZ': '00347', 'TC': '00488', 'TD': '00333', 'TG': '00334', 'TH': '00235', 'TJ': '00228',
'TL': '00282', 'TM': '00229', 'TN': '00357', 'TO': '00616', 'TR': '00262', 'TT': '00422', 'TV': '00621',
'TW': '00204', 'TZ': '00332', 'UA': '00143', 'UG': '00323', 'US': '00402', 'UY': '00519', 'UZ': '00227',
'VA': '00133', 'VC': '00429', 'VE': '00520', 'VG': '00479', 'VI': '00478', 'VN': '00220', 'VU': '00624',
'WF': '00689', 'WS': '00614', 'XK': '00153', 'YE': '00270', 'ZA': '00325', 'ZM': '00335', 'ZW': '00344'
}
class L10n_Be281_45(models.Model):
_name = 'l10n_be.281_45'
_inherit = ['hr.payroll.declaration.mixin']
_description = 'HR Payroll 281.45 Wizard'
_order = 'year'
state = fields.Selection([('generate', 'generate'), ('get', 'get')], default='generate')
is_test = fields.Boolean(string="Is it a test?", default=False)
type_sending = fields.Selection([
('0', 'Original send'),
('1', 'Send grouped corrections'),
], string="Sending Type", default='0', required=True)
type_treatment = fields.Selection([
('0', 'Original'),
('1', 'Modification'),
('2', 'Add'),
('3', 'Cancel'),
], string="Treatment Type", default='0', required=True)
xml_file = fields.Binary('XML File', readonly=True, attachment=False)
xml_filename = fields.Char()
xml_validation_state = fields.Selection([
('normal', 'N/A'),
('done', 'Valid'),
('invalid', 'Invalid'),
], default='normal', compute='_compute_validation_state', store=True)
error_message = fields.Char('Error Message', compute='_compute_validation_state', store=True)
def _country_restriction(self):
return 'BE'
def action_generate_declarations(self):
for sheet in self:
all_payslips = self.env['hr.payslip'].search([
('date_to', '<=', date(int(sheet.year), 12, 31)),
('date_from', '>=', date(int(sheet.year), 1, 1)),
('state', 'in', ['validated', 'paid']),
('company_id', '=', sheet.company_id.id),
('employee_id.employee_type', '!=', 'trainee'),
])
all_employees = all_payslips.mapped('employee_id')
sheet.write({
'line_ids': [(5, 0, 0)] + [(0, 0, {
'employee_id': employee.id,
'res_model': 'l10n_be.281_45',
'res_id': sheet.id,
}) for employee in all_employees]
})
return super().action_generate_declarations()
@api.depends('xml_file')
def _compute_validation_state(self):
xsd_schema_file_path = file_path('l10n_be_hr_payroll/data/Belcotax-2025.xsd')
xsd_root = etree.parse(xsd_schema_file_path)
schema = etree.XMLSchema(xsd_root)
no_xml_file_records = self.filtered(lambda record: not record.xml_file)
no_xml_file_records.update({
'xml_validation_state': 'normal',
'error_message': False})
for record in self - no_xml_file_records:
xml_root = etree.fromstring(base64.b64decode(record.xml_file))
try:
schema.assertValid(xml_root)
record.xml_validation_state = 'done'
except etree.DocumentInvalid as err:
record.xml_validation_state = 'invalid'
record.error_message = str(err)
@api.depends('year', 'is_test')
def _compute_display_name(self):
for record in self:
record.display_name = f'{record.year}{_("- Test") if record.is_test else ""}'
def _check_employees_configuration(self, employees):
invalid_employees = employees.filtered(lambda e: not (e.company_id and e.company_id.street and e.company_id.zip and e.company_id.city and e.company_id.phone and e.company_id.vat))
if invalid_employees:
raise UserError(
self.env._(
"The company is not correctly configured on your employees. Please be sure that the following pieces of information are set: street, zip, city, phone, and VAT:\n%(employees)s",
employees="\n".join(invalid_employees.mapped("name")),
)
)
invalid_employees = employees.filtered(
lambda e: not e.private_street or not e.private_zip or not e.private_city or not e.private_country_id)
if invalid_employees:
raise UserError(_("The following employees don't have a valid private address (with a street, a zip, a city and a country):\n%s", '\n'.join(invalid_employees.mapped('name'))))
invalid_employees = employees.filtered(lambda emp: not emp.version_ids or not emp.version_id)
if invalid_employees:
raise UserError(_('Some employee has no contract:\n%s', '\n'.join(invalid_employees.mapped('name'))))
invalid_employees = employees.filtered(lambda e: e.employee_type != 'trainee' and not e._is_niss_valid())
if invalid_employees:
raise UserError(_('Invalid NISS number for those employees:\n %s', '\n'.join(invalid_employees.mapped('name'))))
invalid_country_codes = employees.private_country_id.filtered(lambda c: c.code not in COUNTRY_CODES)
if invalid_country_codes:
raise UserError(_('Unsupported country code %s. Please contact an administrator.', ', '.join(invalid_country_codes.mapped('code'))))
@api.model
def _get_lang_code(self, lang):
if lang == 'nl_NL':
return 1
elif lang == 'fr_FR':
return 2
elif lang == 'de_DE':
return 3
return 2
@api.model
def _get_country_code(self, country):
return COUNTRY_CODES[country.code]
def _get_rendering_data(self, employees):
# Round to eurocent for XML file, not PDF
round_281_45 = self.env.context.get('round_281_45')
def _to_eurocent(amount):
return int(amount * 100) if round_281_45 else amount
if not self.company_id.vat or not self.company_id.zip:
raise UserError(_('The VAT or the ZIP number is not specified on your company'))
bce_number = self.company_id.vat.replace('BE', '')
if not self.company_id.phone:
raise UserError(_('The phone number is not specified on your company'))
phone = self.company_id.phone.strip().replace(' ', '')
if len(phone) > 12:
raise UserError(_("The company phone number shouldn't exceed 12 characters"))
main_data = {
'v0002_inkomstenjaar': self.year,
'v0010_bestandtype': 'BELCOTST' if self.is_test else 'BELCOTAX',
'v0011_aanmaakdatum': fields.Date.today().strftime('%d-%m-%Y'),
'v0014_naam': self.company_id.name,
'v0015_adres': self.company_id.street,
'v0016_postcode': self.company_id.zip,
'v0017_gemeente': self.company_id.city,
'v0018_telefoonnummer': phone,
'v0021_contactpersoon': self.env.user.name,
'v0022_taalcode': self._get_lang_code(self.env.user.employee_id.lang),
'v0023_emailadres': self.env.user.email,
'v0024_nationaalnr': bce_number,
'v0025_typeenvoi': self.type_sending,
'a1002_inkomstenjaar': self.year,
'a1005_registratienummer': bce_number,
'a1011_naamnl1': self.company_id.name,
'a1013_adresnl': self.company_id.street,
'a1014_postcodebelgisch': self.company_id.zip.strip(),
'a1015_gemeente': self.company_id.city,
'a1016_landwoonplaats': self._get_country_code(self.company_id.country_id),
'a1020_taalcode': 1,
}
employees_data = []
all_payslips = self.env['hr.payslip'].search([
('date_to', '<=', date(int(self.year), 12, 31)),
('date_from', '>=', date(int(self.year), 1, 1)),
('state', 'in', ['validated', 'paid']),
('employee_id', 'in', employees.ids),
])
all_employees = all_payslips.mapped('employee_id')
self._check_employees_configuration(all_employees)
employee_payslips = defaultdict(lambda: self.env['hr.payslip'])
for payslip in all_payslips:
employee_payslips[payslip.employee_id] |= payslip
line_codes = [
'IP',
'IP.DED',
]
all_line_values = all_payslips._get_line_values(line_codes)
belgium = self.env.ref('base.be')
sequence = 0
for employee in employee_payslips:
is_belgium = employee.private_country_id == belgium
payslips = employee_payslips[employee]
mapped_total = {
code: sum(all_line_values[code][p.id]['total'] for p in payslips)
for code in line_codes}
# Skip XML declaration if no IP to declare
if round_281_45 and not round(mapped_total['IP'], 2):
continue
sequence += 1
postcode = employee.private_zip.strip() if is_belgium else '0'
if len(postcode) > 4 or not postcode.isdecimal():
raise UserError(_("The belgian postcode length shouldn't exceed 4 characters and should contain only numbers for employee %s", employee.name))
names = re.sub(r"\([^()]*\)", "", employee.name).strip().split()
first_name = names[-1]
last_name = ' '.join(names[:-1])
if len(first_name) > 30:
raise UserError(_("The employee first name shouldn't exceed 30 characters for employee %s", employee.name))
sheet_values = {
'employee': employee,
'employee_id': employee.id,
'f2002_inkomstenjaar': self.year,
'f2005_registratienummer': bce_number,
'f2008_typefiche': '28145',
'f2009_volgnummer': sequence,
'f2011_nationaalnr': employee.niss,
'f2013_naam': last_name,
'f2015_adres': employee.private_street,
'f2016_postcodebelgisch': postcode,
'employee_city': employee.private_city,
'f2018_landwoonplaats': '150' if is_belgium else self._get_country_code(employee.private_country_id),
'f2027_taalcode': self._get_lang_code(employee.lang),
'f2028_typetraitement': self.type_treatment,
'f2029_enkelopgave325': 0,
'f2112_buitenlandspostnummer': employee.private_zip if not is_belgium else '0',
'f2114_voornamen': first_name,
'f45_2030_aardpersoon': 1,
'f45_2031_verantwoordingsstukken': 0,
# Note: 2060 > 2063
'f45_2063_roerendevoorheffing': _to_eurocent(round(-mapped_total['IP.DED'], 2)),
'f45_2067_paidamount4': 0,
'f45_2068_bookedamount4': 0,
'f45_2069_paidamount51': _to_eurocent(round(mapped_total['IP'], 2)),
'f45_2070_paidamount52': 0,
'f45_2071_bookedamount5': _to_eurocent(round(mapped_total['IP'] / 2.0, 2)),
'f45_2099_comment': '',
'f45_2109_fiscaalidentificat': '', # Use NISS instead
'f45_2110_kbonbr': 0, # N° BCE d’une personne physique (facultatif)
}
# Le code postal belge (2016) et le code postal étranger (2112) ne peuvent être
# ni remplis, ni vides tous les deux.
if is_belgium:
sheet_values.pop('f2112_buitenlandspostnummer')
else:
sheet_values.pop('f2016_postcodebelgisch')
employees_data.append(sheet_values)
# Somme de 2060 à 2088, f10_2062_totaal et f10_2077_totaal inclus
sheet_values['f45_2059_totaalcontrole'] = sum(sheet_values[code] for code in [
'f45_2063_roerendevoorheffing',
'f45_2067_paidamount4',
'f45_2068_bookedamount4',
'f45_2069_paidamount51',
'f45_2070_paidamount52',
'f45_2071_bookedamount5'])
sheets_count = len(employees_data)
sum_2009 = sum(sheet_values['f2009_volgnummer'] for sheet_values in employees_data)
sum_2059 = sum(sheet_values['f45_2059_totaalcontrole'] for sheet_values in employees_data)
sum_2063 = sum(sheet_values['f45_2063_roerendevoorheffing'] for sheet_values in employees_data)
total_data = {
'r8002_inkomstenjaar': self.year,
'r8005_registratienummer': bce_number,
'r8010_aantalrecords': sheets_count + 2,
'r8011_controletotaal': sum_2009,
'r8012_controletotaal': sum_2059,
'r8013_totaalvoorheffingen': sum_2063,
'r9002_inkomstenjaar': self.year,
'r9010_aantallogbestanden': 3,
'r9011_totaalaantalrecords': sheets_count + 4,
'r9012_controletotaal': sum_2009,
'r9013_controletotaal': sum_2059,
'r9014_controletotaal': sum_2063,
}
return {'data': main_data, 'employees_data': employees_data, 'total_data': total_data}
def action_generate_xml(self):
self.ensure_one()
self.xml_filename = '%s-281_45_report.xml' % (self.year)
xml_str = self.env['ir.qweb']._render(
'l10n_be_hr_payroll.281_45_xml_report',
self.with_context(round_281_45=True)._get_rendering_data(self.line_ids.employee_id))
# Prettify xml string
root = etree.fromstring(xml_str, parser=etree.XMLParser(remove_blank_text=True))
xml_formatted_str = etree.tostring(root, pretty_print=True, encoding='utf-8', xml_declaration=True)
self.xml_file = base64.encodebytes(xml_formatted_str)
self.state = 'get'
def _get_pdf_report(self):
return self.env.ref('l10n_be_hr_payroll.action_report_employee_281_45')
def _get_pdf_filename(self, employee):
self.ensure_one()
return _('%(year)s-%(employee)s-281_45', year=self.year, employee=employee.name)
def _post_process_rendering_data_pdf(self, rendering_data):
result = {}
for sheet_values in rendering_data['employees_data']:
for key, value in sheet_values.items():
if isinstance(value, int) and value == 0:
sheet_values[key] = '0.00 €'
elif isinstance(value, float):
sheet_values[key] = '{:,.2f} €'.format(value)
elif not value:
sheet_values[key] = _('None')
result[sheet_values['employee']] = {**sheet_values, **rendering_data['data']}
return result