1545 lines
84 KiB
Python
1545 lines
84 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from pytz import timezone
|
|
from dateutil.relativedelta import relativedelta, MO, SU
|
|
from dateutil import rrule
|
|
from calendar import monthrange
|
|
from collections import defaultdict, Counter
|
|
from datetime import date, timedelta
|
|
from itertools import chain
|
|
|
|
from odoo import api, Command, models, fields, _
|
|
from odoo.tools import SQL, float_round, float_is_zero, ormcache
|
|
|
|
|
|
class HrPayslip(models.Model):
|
|
_inherit = 'hr.payslip'
|
|
|
|
meal_voucher_count = fields.Integer(
|
|
compute='_compute_work_entry_dependent_benefits') # Overrides compute method
|
|
# YTI NOTE: Changed nature of field (from missing dats to granting days)
|
|
private_car_missing_days = fields.Integer(
|
|
string='Days Granting Private Car Reimbursement',
|
|
compute='_compute_work_entry_dependent_benefits')
|
|
representation_fees_missing_days = fields.Integer(
|
|
string='Days Not Granting Representation Fees',
|
|
compute='_compute_work_entry_dependent_benefits')
|
|
l10n_be_is_double_pay = fields.Boolean(compute='_compute_l10n_be_is_double_pay')
|
|
l10n_be_max_seizable_amount = fields.Float(compute='_compute_l10n_be_max_seizable_amount')
|
|
l10n_be_max_seizable_warning = fields.Char(compute='_compute_l10n_be_max_seizable_amount')
|
|
l10n_be_is_december = fields.Boolean(compute='_compute_l10n_be_is_december')
|
|
l10n_be_has_eco_vouchers = fields.Boolean(compute='_compute_l10n_be_has_eco_vouchers', search='_search_l10n_be_has_eco_vouchers')
|
|
|
|
@api.depends('employee_id', 'version_id', 'struct_id', 'date_from', 'date_to')
|
|
def _compute_input_line_ids(self):
|
|
res = super()._compute_input_line_ids()
|
|
balance_by_employee = self._get_salary_advance_balances()
|
|
for slip in self:
|
|
if not slip.employee_id or not slip.date_from or not slip.date_to or slip.country_code != 'BE':
|
|
continue
|
|
# If a double holiday pay should be recovered
|
|
if slip.struct_id.code == 'CP200DOUBLE':
|
|
to_recover = slip._get_sum_european_time_off_days()
|
|
if to_recover:
|
|
european_type = self.env.ref('l10n_be_hr_payroll.input_double_holiday_european_leave_deduction')
|
|
lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id == european_type)
|
|
to_remove_vals = [(3, line.id, False) for line in lines_to_remove]
|
|
to_add_vals = [(0, 0, {
|
|
'name': _('European Leaves Deduction'),
|
|
'amount': to_recover,
|
|
'input_type_id': european_type.id,
|
|
})]
|
|
slip.write({'input_line_ids': to_remove_vals + to_add_vals})
|
|
elif slip.struct_id.code == 'CP200SALARYADV':
|
|
sal_adv_type = self.env.ref('l10n_be_hr_payroll.input_salary_advance')
|
|
sal_adv_rec_type = self.env.ref('l10n_be_hr_payroll.cp200_input_advance')
|
|
lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id in (sal_adv_type, sal_adv_rec_type))
|
|
to_remove_vals = [(3, line.id, False) for line in lines_to_remove]
|
|
to_add_vals = [(0, 0, {
|
|
'name': _('Salary Advance'),
|
|
'amount': 0,
|
|
'input_type_id': sal_adv_type.id,
|
|
})]
|
|
slip.write({'input_line_ids': to_remove_vals + to_add_vals})
|
|
elif slip.struct_id.code == 'CP200MONTHLY':
|
|
balance = balance_by_employee[slip.employee_id]
|
|
if balance <= 0:
|
|
continue
|
|
sal_adv_type = self.env.ref('l10n_be_hr_payroll.input_salary_advance')
|
|
sal_adv_rec_type = self.env.ref('l10n_be_hr_payroll.cp200_input_advance')
|
|
lines_to_remove = slip.input_line_ids.filtered(
|
|
lambda x: x.input_type_id in (sal_adv_rec_type, sal_adv_type)
|
|
)
|
|
to_remove_vals = [(3, line.id, False) for line in lines_to_remove]
|
|
to_add_vals = [(0, 0, {
|
|
'name': _('Salary Advance Recovery'),
|
|
'amount': balance,
|
|
'input_type_id': sal_adv_rec_type.id,
|
|
})]
|
|
slip.write({'input_line_ids': to_remove_vals + to_add_vals})
|
|
elif slip.struct_id.code == 'CP200CCT90':
|
|
cct90_input_type = self.env.ref('l10n_be_hr_payroll.input_cct90_bonus_plan')
|
|
lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id == cct90_input_type)
|
|
to_remove_vals = [Command.unlink(line.id) for line in lines_to_remove]
|
|
to_add_vals = [Command.create({
|
|
'name': _('CCT90 Bonus Plan'),
|
|
'amount': 0,
|
|
'input_type_id': cct90_input_type.id,
|
|
})]
|
|
slip.write({'input_line_ids': to_remove_vals + to_add_vals})
|
|
return res
|
|
|
|
@ormcache('self.employee_id', 'self.date_from', 'self.date_to')
|
|
def _get_period_contracts(self):
|
|
# Returns all the employee contracts over the same payslip period, to avoid
|
|
# double remunerations for some line codes
|
|
self.ensure_one()
|
|
if self.env.context.get('salary_simulation'):
|
|
return self.env.context['origin_version_id']
|
|
contracts = self.employee_id._get_versions_with_contract_overlap_with_period(
|
|
self.date_from,
|
|
self.date_to,
|
|
).sorted('date_start')
|
|
return contracts.ids
|
|
|
|
def _get_max_basic_salary_contract(self, contracts):
|
|
self.ensure_one()
|
|
if len(contracts) == 1:
|
|
return contracts
|
|
credit_time_work_entry_type_ids = [
|
|
self.env['ir.model.data']._xmlid_to_res_model_res_id('hr_work_entry.l10n_be_work_entry_type_credit_time')[1],
|
|
self.env['ir.model.data']._xmlid_to_res_model_res_id('hr_work_entry.l10n_be_work_entry_type_parental_time_off')[1],
|
|
]
|
|
unpaid_work_entry_types = self.struct_id.unpaid_work_entry_type_ids.ids + credit_time_work_entry_type_ids
|
|
for contract in contracts:
|
|
date_end = min([contract.date_end, self.date_to]) if contract.date_end else self.date_to
|
|
all_work_hours = contract.get_work_hours(self.date_from, date_end)
|
|
work_hours = sum(
|
|
hours for work_entry_type_id, hours in all_work_hours.items() \
|
|
if work_entry_type_id not in unpaid_work_entry_types)
|
|
if not float_is_zero(work_hours, precision_digits=2):
|
|
return contract
|
|
return contracts[0]
|
|
|
|
@api.depends('worked_days_line_ids.number_of_hours', 'worked_days_line_ids.is_paid',
|
|
'worked_days_line_ids.work_entry_type_id.l10n_be_is_time_credit')
|
|
def _compute_worked_hours(self):
|
|
super()._compute_worked_hours()
|
|
for payslip in self:
|
|
payslip.sum_worked_hours -= sum(
|
|
line.number_of_hours for line in payslip.worked_days_line_ids
|
|
if line.work_entry_type_id.l10n_be_is_time_credit
|
|
)
|
|
|
|
@api.depends('struct_id', 'date_from')
|
|
def _compute_l10n_be_is_december(self):
|
|
for payslip in self:
|
|
payslip.l10n_be_is_december = payslip.struct_id.code == "CP200MONTHLY" and payslip.date_from and payslip.date_from.month == 12
|
|
|
|
def _compute_work_entry_dependent_benefits(self):
|
|
if self.env.context.get('salary_simulation'):
|
|
for payslip in self:
|
|
payslip.meal_voucher_count = 20
|
|
payslip.private_car_missing_days = 20
|
|
payslip.representation_fees_missing_days = 0
|
|
else:
|
|
all_benefits = self.env['hr.work.entry.type'].get_work_entry_type_benefits()
|
|
query = self.env['l10n_be.work.entry.daily.benefit.report']._search([
|
|
('employee_id', 'in', self.mapped('employee_id').ids),
|
|
('day', '<=', max(self.mapped('date_to'))),
|
|
('day', '>=', min(self.mapped('date_from'))),
|
|
])
|
|
work_entries_benefits_rights = self.env.execute_query_dict(
|
|
query.select('day', 'benefit_name', 'employee_id'))
|
|
|
|
work_entries_benefits_rights_by_employee = defaultdict(list)
|
|
for work_entries_benefits_right in work_entries_benefits_rights:
|
|
employee_id = work_entries_benefits_right['employee_id']
|
|
work_entries_benefits_rights_by_employee[employee_id].append(work_entries_benefits_right)
|
|
|
|
# {(calendar, date_from, date_to): resources}
|
|
mapped_resources = defaultdict(lambda: self.env['resource.resource'])
|
|
for payslip in self:
|
|
contract = payslip.version_id
|
|
calendar = contract.resource_calendar_id if not contract.l10n_be_time_credit else contract.standard_calendar_id
|
|
mapped_resources[(calendar, payslip.date_from, payslip.date_to)] |= contract.employee_id.resource_id
|
|
# {(calendar, date_from, date_to): intervals}}
|
|
mapped_intervals = {}
|
|
for (calendar, date_from, date_to), resources in mapped_resources.items():
|
|
tz = timezone(calendar.tz)
|
|
mapped_intervals[(calendar, date_from, date_to)] = calendar._attendance_intervals_batch(
|
|
tz.localize(fields.Datetime.to_datetime(date_from)),
|
|
tz.localize(fields.Datetime.to_datetime(date_to) + timedelta(days=1, seconds=-1)),
|
|
resources=resources, tz=tz)
|
|
|
|
for payslip in self:
|
|
contract = payslip.version_id
|
|
benefits = dict.fromkeys(all_benefits, 0)
|
|
date_from = max(payslip.date_from, contract.date_start)
|
|
date_to = min(payslip.date_to, contract.date_end or payslip.date_to)
|
|
for work_entries_benefits_right in (
|
|
work_entries_benefits_right
|
|
for work_entries_benefits_right in work_entries_benefits_rights_by_employee[payslip.employee_id.id]
|
|
if date_from <= work_entries_benefits_right['day'] <= date_to
|
|
):
|
|
if work_entries_benefits_right['benefit_name'] not in benefits:
|
|
benefits[work_entries_benefits_right['benefit_name']] = 1
|
|
else:
|
|
benefits[work_entries_benefits_right['benefit_name']] += 1
|
|
|
|
contract = payslip.version_id
|
|
resource = contract.employee_id.resource_id
|
|
calendar = contract.resource_calendar_id if not contract.l10n_be_time_credit else contract.standard_calendar_id
|
|
intervals = mapped_intervals[(calendar, payslip.date_from, payslip.date_to)][resource.id]
|
|
|
|
nb_of_days_to_work = len({dt_from.date(): True for (dt_from, dt_to, attendance) in intervals})
|
|
payslip.private_car_missing_days = benefits['private_car'] if 'private_car' in benefits else 0
|
|
payslip.representation_fees_missing_days = nb_of_days_to_work - (benefits['representation_fees'] if 'representation_fees' in benefits else 0)
|
|
payslip.meal_voucher_count = benefits['meal_voucher']
|
|
|
|
@api.depends('struct_id')
|
|
def _compute_l10n_be_is_double_pay(self):
|
|
for payslip in self:
|
|
payslip.l10n_be_is_double_pay = payslip.struct_id.code == "CP200DOUBLE"
|
|
|
|
@api.depends('input_line_ids')
|
|
def _compute_l10n_be_has_eco_vouchers(self):
|
|
for slip in self:
|
|
slip.l10n_be_has_eco_vouchers = any(input_line.code == 'ECOVOUCHERS' for input_line in slip.input_line_ids)
|
|
|
|
def _search_l10n_be_has_eco_vouchers(self, operator, value):
|
|
if operator != 'in':
|
|
return NotImplemented
|
|
rows = self.env.execute_query(SQL("""
|
|
SELECT id
|
|
FROM hr_payslip payslip
|
|
WHERE EXISTS
|
|
(SELECT 1
|
|
FROM hr_payslip_input hpi
|
|
JOIN hr_payslip_input_type hpit
|
|
ON hpi.input_type_id = hpit.id AND hpit.code = 'ECOVOUCHERS'
|
|
WHERE hpi.payslip_id = payslip.id
|
|
LIMIT 1)
|
|
"""))
|
|
return [('id', 'in', [r[0] for r in rows])]
|
|
|
|
@api.depends('date_to', 'line_ids.total', 'input_line_ids.code')
|
|
def _compute_l10n_be_max_seizable_amount(self):
|
|
# Source: https://emploi.belgique.be/fr/themes/remuneration/protection-de-la-remuneration/saisie-et-cession-sur-salaires
|
|
all_payslips = self.env['hr.payslip'].search([
|
|
('employee_id', 'in', self.employee_id.ids),
|
|
('state', '!=', 'cancel')])
|
|
payslip_values = all_payslips._get_line_values(['NET'])
|
|
for payslip in self:
|
|
if payslip.struct_id.country_id.code != 'BE':
|
|
payslip.l10n_be_max_seizable_amount = 0
|
|
payslip.l10n_be_max_seizable_warning = False
|
|
continue
|
|
|
|
rates = self.env['hr.rule.parameter']._get_parameter_from_code('cp200_seizable_percentages', payslip.date_to, raise_if_not_found=False)
|
|
child_increase = self.env['hr.rule.parameter']._get_parameter_from_code('cp200_seizable_amount_child', payslip.date_to, raise_if_not_found=False)
|
|
if not rates or not child_increase:
|
|
payslip.l10n_be_max_seizable_amount = 0
|
|
payslip.l10n_be_max_seizable_warning = False
|
|
continue
|
|
|
|
# Note: the ceiling amounts are based on the net revenues
|
|
period_payslips = all_payslips.filtered(
|
|
lambda p: p.employee_id == payslip.employee_id and p.date_from == payslip.date_from and p.date_to == payslip.date_to)
|
|
net_amount = sum([payslip_values['NET'][p.id]['total'] for p in period_payslips])
|
|
seized_amount = sum([period_payslips._get_input_line_amount(code) for code in ['ATTACH_SALARY', 'ASSIG_SALARY', 'CHILD_SUPPORT']])
|
|
net_amount += seized_amount
|
|
# Note: The reduction for dependant children is not applied most of the time because
|
|
# the process is too complex.
|
|
# To benefit from this increase in the elusive or non-transferable quotas, the worker
|
|
# whose remuneration is subject to seizure or transfer, must declare it using a form,
|
|
# the model of which has been published in the Belgian Official Gazette. of 30 November
|
|
# 2006.
|
|
# He must attach to this form the documents establishing the reality of the
|
|
# charge invoked.
|
|
# Source: Opinion on the indexation of the amounts set in Article 1, paragraph 4, of
|
|
# the Royal Decree of 27 December 2004 implementing Articles 1409, § 1, paragraph 4,
|
|
# and 1409, § 1 bis, paragraph 4 , of the Judicial Code relating to the limitation of
|
|
# seizure when there are dependent children, MB, December 13, 2019.
|
|
dependent_children = payslip.employee_id.l10n_be_dependent_children_attachment
|
|
max_seizable_amount = 0
|
|
for left, right, rate in rates:
|
|
if dependent_children:
|
|
left += dependent_children * child_increase
|
|
right += dependent_children * child_increase
|
|
if left <= net_amount:
|
|
max_seizable_amount += (min(net_amount, right) - left) * rate
|
|
payslip.l10n_be_max_seizable_amount = max_seizable_amount
|
|
if max_seizable_amount and seized_amount > max_seizable_amount:
|
|
payslip.l10n_be_max_seizable_warning = _(
|
|
'The seized amount (%(seized_amount)s€) is above the belgian ceilings. Given a global net salary of %(net_amount)s€ for the pay period and %(dependent_children)s dependent children, the maximum seizable amount is equal to %(max_seizable_amount)s€',
|
|
seized_amount=round(seized_amount, 2),
|
|
net_amount=round(net_amount, 2),
|
|
dependent_children=round(dependent_children, 2),
|
|
max_seizable_amount=round(max_seizable_amount, 2),
|
|
)
|
|
else:
|
|
payslip.l10n_be_max_seizable_warning = False
|
|
|
|
def _get_salary_advance_balances(self):
|
|
balance_by_employee = super()._get_salary_advance_balances()
|
|
payslips_by_employee = self._read_group(
|
|
domain=[
|
|
('struct_id.country_id', '=', 'BE'),
|
|
('state', 'in', ('validated', 'paid')),
|
|
('employee_id', 'in', self.employee_id.ids),
|
|
('input_line_ids.code', 'in', ('SALARYADVREC', 'SALARYADV')),
|
|
],
|
|
groupby=['employee_id'],
|
|
aggregates=['id:recordset']
|
|
)
|
|
for employee_id, payslips in payslips_by_employee:
|
|
for input_line in chain.from_iterable(payslip.input_line_ids for payslip in payslips):
|
|
if input_line.code == 'SALARYADV':
|
|
balance_by_employee[employee_id] += input_line.amount
|
|
elif input_line.code == 'SALARYADVREC':
|
|
balance_by_employee[employee_id] -= input_line.amount
|
|
return balance_by_employee
|
|
|
|
def _get_worked_day_lines_hours_per_day(self):
|
|
self.ensure_one()
|
|
if self.version_id.l10n_be_time_credit:
|
|
return self.version_id.standard_calendar_id.hours_per_day
|
|
return super()._get_worked_day_lines_hours_per_day()
|
|
|
|
def _get_out_of_contract_calendar(self):
|
|
self.ensure_one()
|
|
if self.version_id.l10n_be_time_credit:
|
|
return self.version_id.standard_calendar_id
|
|
return super()._get_out_of_contract_calendar()
|
|
|
|
def _get_worked_day_lines_values(self, domain=None):
|
|
self.ensure_one()
|
|
res = []
|
|
if self.struct_id.country_id.code != 'BE':
|
|
return super()._get_worked_day_lines_values(domain=domain)
|
|
# If a belgian payslip has half-day attendances/time off, it the worked days lines should
|
|
# be separated
|
|
work_hours = self.version_id._get_work_hours_split_half(self.date_from, self.date_to, domain=domain)
|
|
work_hours_ordered = sorted(work_hours.items(), key=lambda x: x[1])
|
|
for worked_days_data, duration_data in work_hours_ordered:
|
|
duration_type, work_entry_type_id = worked_days_data
|
|
number_of_days, number_of_hours = duration_data
|
|
work_entry_type = self.env['hr.work.entry.type'].browse(work_entry_type_id)
|
|
attendance_line = {
|
|
'sequence': work_entry_type.sequence,
|
|
'work_entry_type_id': work_entry_type_id,
|
|
'number_of_days': number_of_days,
|
|
'number_of_hours': number_of_hours,
|
|
'name': self.env._('%(name)s%(duration_type)s', name=work_entry_type.name, duration_type=self.env._(' (Half-Day)') if duration_type == 'half' else ""),
|
|
}
|
|
res.append(attendance_line)
|
|
# If there is a public holiday less than 30 days after the end of the contract
|
|
# this public holiday should be taken into account in the worked days lines
|
|
if self.version_id.date_end and self.date_from <= self.version_id.date_end <= self.date_to:
|
|
# If the contract is followed by another one (eg. after an appraisal)
|
|
if not self.version_id.employee_id.version_ids.filtered(lambda v: v.date_start > self.version_id.date_end):
|
|
public_holiday_type = self.env.ref('hr_work_entry.l10n_be_work_entry_type_bank_holiday')
|
|
public_leaves = self.version_id.resource_calendar_id.global_leave_ids.filtered(
|
|
lambda l: l.work_entry_type_id == public_holiday_type)
|
|
# If less than 15 days under contract, the public holidays is not reimbursed
|
|
public_leaves = public_leaves.filtered(
|
|
lambda l: (l.date_from.date() - self.employee_id.contract_date_start).days >= 15)
|
|
# If less than 15 days of occupation -> no payment of the time off after contract
|
|
# If less than 1 month of occupation -> payment of the time off occurring within 15 days after contract.
|
|
# Occupation = duration since the start of the contract, from date to date
|
|
public_leaves = public_leaves.filtered(
|
|
lambda l: 0 < (l.date_from.date() - self.version_id.date_end).days <= (30 if self.employee_id.contract_date_start + relativedelta(months=1) <= self.version_id.date_end else 15))
|
|
if public_leaves:
|
|
input_type_id = self.env.ref('l10n_be_hr_payroll.cp200_other_input_after_contract_public_holidays').id
|
|
if input_type_id not in self.input_line_ids.mapped('input_type_id').ids:
|
|
self.write({'input_line_ids': [(0, 0, {
|
|
'name': _('After Contract Public Holidays'),
|
|
'amount': 0.0,
|
|
'input_type_id': self.env.ref('l10n_be_hr_payroll.cp200_other_input_after_contract_public_holidays').id,
|
|
})]})
|
|
# Handle loss on commissions
|
|
if self.version_id.commission_on_target and self._get_last_year_average_variable_revenues():
|
|
we_types_ids = (
|
|
self.env.ref('hr_work_entry.l10n_be_work_entry_type_bank_holiday') + self.env.ref('hr_work_entry.l10n_be_work_entry_type_small_unemployment')
|
|
).ids
|
|
# if self.worked_days_line_ids.filtered(lambda wd: wd.code in ['LEAVE205', 'LEAVE500']):
|
|
if any(line_vals['work_entry_type_id'] in we_types_ids for line_vals in res):
|
|
we_type = self.env.ref('hr_work_entry.l10n_be_work_entry_type_simple_holiday_pay_variable_salary')
|
|
res.append({
|
|
'sequence': we_type.sequence,
|
|
'work_entry_type_id': we_type.id,
|
|
'number_of_days': 0,
|
|
'number_of_hours': 0,
|
|
})
|
|
return res
|
|
|
|
def _get_last_year_average_variable_revenues(self):
|
|
date_from = self.env.context.get('variable_revenue_date_from', self.date_from)
|
|
first_version_date = self.employee_id._get_first_version_date()
|
|
if not first_version_date:
|
|
return 0
|
|
start = first_version_date
|
|
end = date_from + relativedelta(day=31, months=-1)
|
|
number_of_month = (end.year - start.year) * 12 + (end.month - start.month) + 1
|
|
number_of_month = min(12, number_of_month)
|
|
if number_of_month <= 0:
|
|
return 0
|
|
payslips = self.env['hr.payslip'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('state', 'in', ['validated', 'paid']),
|
|
('date_from', '>=', date_from + relativedelta(months=-12, day=1)),
|
|
('date_from', '<=', date_from),
|
|
], order="date_from asc")
|
|
total_amount = payslips._get_line_values(['COMMISSION'], compute_sum=True)['COMMISSION']['sum']['total']
|
|
return total_amount / number_of_month if number_of_month else 0
|
|
|
|
def _get_last_year_average_warrant_revenues(self):
|
|
warrant_payslips = self.env['hr.payslip'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('state', 'in', ['validated', 'paid']),
|
|
('struct_id.code', '=', 'CP200WARRANT'),
|
|
('date_from', '>=', self.date_from + relativedelta(months=-12, day=1)),
|
|
('date_from', '<', self.date_from),
|
|
], order="date_from asc")
|
|
total_amount = warrant_payslips._get_line_values(['BASIC'], compute_sum=True)['BASIC']['sum']['total']
|
|
first_version_date = self.employee_id._get_first_version_date()
|
|
if not first_version_date:
|
|
return 0
|
|
# Only complete months count
|
|
if first_version_date.day != 1:
|
|
start = first_version_date + relativedelta(day=1, months=1)
|
|
else:
|
|
start = first_version_date
|
|
end = self.date_from + relativedelta(day=31, months=-1)
|
|
number_of_month = (end.year - start.year) * 12 + (end.month - start.month) + 1
|
|
number_of_month = min(12, number_of_month)
|
|
return total_amount / number_of_month if number_of_month else 0
|
|
|
|
def _compute_number_complete_months_of_work(self, date_from, date_to, contracts, use_work_rate=False):
|
|
days_by_contract_by_year = defaultdict(lambda: defaultdict(dict))
|
|
for day in rrule.rrule(rrule.DAILY, dtstart=date_from + relativedelta(day=1), until=date_to + relativedelta(day=31)):
|
|
days_by_contract_by_year[day.year][day.month][day.date()] = None
|
|
|
|
public_holidays = [(leave.date_from.date(), leave.date_to.date()) for leave in self.employee_id._get_public_holidays(date_from, date_to)]
|
|
for contract in contracts:
|
|
work_days = {int(d) for d in contract.resource_calendar_id._get_global_attendances().mapped('dayofweek')}
|
|
|
|
previous_week_start = max(contract.date_start + relativedelta(weeks=-1, weekday=MO(-1)), date_from + relativedelta(day=1))
|
|
next_week_end = min(contract.date_end + relativedelta(weeks=+1, weekday=SU(+1)) if contract.date_end else date.max, date_to)
|
|
days_to_check = rrule.rrule(rrule.DAILY, dtstart=previous_week_start, until=next_week_end)
|
|
for day in days_to_check:
|
|
day = day.date()
|
|
|
|
# Full time credit time doesn't count
|
|
if contract.l10n_be_time_credit and not contract.work_time_rate:
|
|
continue
|
|
if contract.date_start <= day <= (contract.date_end or date.max):
|
|
days_by_contract_by_year[day.year][day.month][day] = contract
|
|
elif (day.weekday() not in work_days or
|
|
any(date_from <= day <= date_to for date_from, date_to in public_holidays)):
|
|
days_by_contract_by_year[day.year][day.month][day] = 'holiday'
|
|
|
|
months = 0
|
|
for invalid_days_by_months in days_by_contract_by_year.values():
|
|
for days in invalid_days_by_months.values():
|
|
counter = Counter(days.values())
|
|
if None in counter:
|
|
continue
|
|
if use_work_rate:
|
|
under_contract_days = sum(counter.values()) - counter.pop('holiday', 0)
|
|
for contract, n_days in counter.items():
|
|
months += n_days / under_contract_days * contract.work_time_rate
|
|
else:
|
|
months += 1
|
|
return months
|
|
|
|
def _compute_presence_prorated_fixed_wage(self, date_from, date_to, versions):
|
|
self.ensure_one()
|
|
|
|
def _get_calendar_days(leave, date_min, date_max):
|
|
date_from = max(date_min, leave.date_from.date())
|
|
date_to = min(date_max, leave.date_to.date())
|
|
days = (date_to - date_from).days + 1
|
|
if leave.request_unit_half:
|
|
if leave.request_date_from_period == leave.request_date_to_period:
|
|
return days - 0.5
|
|
elif leave.request_date_from_period == 'pm' and leave.request_date_to_period == 'am':
|
|
return days - 1
|
|
return days
|
|
|
|
def round_half_days(duration):
|
|
return round(duration * 2) / 2
|
|
|
|
work_entries = self.env['hr.work.entry'].search([
|
|
('state', 'in', ['validated', 'draft']),
|
|
('employee_id', '=', self.employee_id.id),
|
|
('date', '>=', date_from),
|
|
('date', '<=', date_to),
|
|
], order="date asc")
|
|
work_entries_by_date = work_entries.grouped('date')
|
|
unpaid_work_entry_types = self.env['hr.work.entry.type']
|
|
unpaid_leave_type = self.env.ref('hr_work_entry.work_entry_type_unpaid_leave', raise_if_not_found=False)
|
|
if unpaid_leave_type:
|
|
unpaid_work_entry_types += unpaid_leave_type
|
|
unjustified_reason_type = self.env.ref('hr_work_entry.l10n_be_work_entry_type_unjustified_reason', raise_if_not_found=False)
|
|
if unjustified_reason_type:
|
|
unpaid_work_entry_types += unjustified_reason_type
|
|
is_PFA = self.struct_id.code == 'CP200THIRTEEN'
|
|
fte_basic = self.version_id._get_contract_wage() * 1 / self.version_id.work_time_rate if self.version_id.work_time_rate else 0
|
|
company_avg_hours_per_day = self.company_id.resource_calendar_id.hours_per_day
|
|
|
|
version_by_date = defaultdict(lambda: defaultdict(dict))
|
|
for day in rrule.rrule(rrule.DAILY, dtstart=date_from + relativedelta(day=1), until=date_to + relativedelta(day=31)):
|
|
version_by_date[day.year][day.month][day.date()] = None
|
|
|
|
full_unpaid_months = set()
|
|
for version in versions:
|
|
working_days = version.resource_calendar_id._get_working_hours()
|
|
|
|
# As version changes can go from friday to monday, we need to extend the search to the previous monday/next sunday
|
|
# We only check for next sunday for the last version otherwise we would write the wrong version
|
|
previous_week_start = max(version.date_start + relativedelta(weeks=-1, weekday=MO(-1)), date_from + relativedelta(day=1))
|
|
if version == versions[-1]:
|
|
date_end = min(version.date_end + relativedelta(weeks=+1, weekday=SU(+1)) if version.date_end else date.max, date_to)
|
|
else:
|
|
date_end = min(version.date_end or date.max, date_to)
|
|
days_to_check = rrule.rrule(rrule.DAILY, dtstart=previous_week_start, until=date_end)
|
|
|
|
for day in days_to_check:
|
|
day = day.date()
|
|
work_entries_date = work_entries_by_date.get(day)
|
|
|
|
if version_by_date[day.year][day.month][day] or day.month in full_unpaid_months:
|
|
continue
|
|
|
|
dayofweek = str(day.weekday())
|
|
if version.resource_calendar_id.two_weeks_calendar:
|
|
weektype = str(self.env['resource.calendar.attendance'].get_week_type(day))
|
|
is_calendar_day = working_days[weektype][dayofweek]
|
|
else:
|
|
is_calendar_day = working_days[False][dayofweek]
|
|
|
|
if is_calendar_day and (not work_entries_date or (is_PFA and all(we.work_entry_type_id in unpaid_work_entry_types for we in work_entries_date))):
|
|
full_unpaid_months.add(day.month)
|
|
else:
|
|
version_by_date[day.year][day.month][day] = version
|
|
|
|
full_time_months = 0
|
|
payslip_amount = 0
|
|
|
|
### PFA STUFF ###
|
|
sick_work_entry_type_codes = [
|
|
'LEAVE110', # Sick Time Off
|
|
'LEAVE280', # Long Term Sick
|
|
'LEAVE214', # Sick Time Off (Without Guaranteed Salary)
|
|
'LEAVE281', # Partial Incapacity
|
|
]
|
|
covered_time_offs = self.env['hr.leave']
|
|
pfa_calendar_sick_days_remaining = 60
|
|
pfa_sick_calendar_days_to_defer = 0 # used to report time off to deduct to next month
|
|
pfa_unpredictable_days_remaining = 10
|
|
sick_work_entries = work_entries.filtered_domain([
|
|
('work_entry_type_id.code', 'in', sick_work_entry_type_codes)
|
|
])
|
|
unpredictable_work_entries = work_entries.filtered_domain([
|
|
('work_entry_type_id.code', '=', 'LEAVE250')
|
|
])
|
|
|
|
if is_PFA:
|
|
# If the first work entry of the year is a sick work entry, check for last year work entries
|
|
# If already sick in december last year, we should deduct these days, except if there is a period of 14 calendar days of work
|
|
if work_entries and work_entries[0].work_entry_type_id.code in sick_work_entry_type_codes:
|
|
last_year_work_entries = self.env['hr.work.entry'].search([
|
|
('state', 'in', ['draft', 'validated']),
|
|
('employee_id', '=', self.employee_id.id),
|
|
('date', '>=', date_from - relativedelta(years=1)),
|
|
('date', '<=', date_to - relativedelta(years=1)),
|
|
], order="date asc")
|
|
if last_year_work_entries and last_year_work_entries[-1].work_entry_type_id.code in sick_work_entry_type_codes:
|
|
last_year_sick_work_entries = last_year_work_entries.filtered_domain([
|
|
('work_entry_type_id.code', 'in', sick_work_entry_type_codes)
|
|
])
|
|
# Check if has worked for 14 consecutive days. If yes, then do not deduct last year sick days
|
|
# We ignore half days of sickness, as sick time off are not taken in half days in Belgium
|
|
has_worked_consecutive_14_days = False
|
|
consecutive_work_days_counter = 0
|
|
current_date = date_from
|
|
while current_date < date_to:
|
|
work_entries = work_entries_by_date.get(current_date)
|
|
if not work_entries or any(we.work_entry_type_id.code not in sick_work_entry_type_codes for we in work_entries):
|
|
consecutive_work_days_counter += 1
|
|
else:
|
|
consecutive_work_days_counter = 0
|
|
if leave_work_entries := work_entries.filtered('leave_id'):
|
|
current_date = max(leave_work_entries.leave_id.mapped('date_to')).date() + relativedelta(days=1)
|
|
continue
|
|
|
|
# If worked 14 calendar consecutive days, counter is set to 60 days for the year
|
|
if consecutive_work_days_counter >= 14:
|
|
has_worked_consecutive_14_days = True
|
|
break
|
|
current_date += relativedelta(days=1)
|
|
|
|
if not has_worked_consecutive_14_days:
|
|
last_year_sick_calendar_days = 0
|
|
last_year_covered_time_offs = self.env['hr.leave']
|
|
for sick_wes in last_year_sick_work_entries.grouped('date').values():
|
|
for sick_we in sick_wes:
|
|
if sick_we.leave_id and not sick_we.leave_id in last_year_covered_time_offs:
|
|
last_year_sick_calendar_days += _get_calendar_days(
|
|
sick_we.leave_id, date_from - relativedelta(years=1), date_to - relativedelta(years=1)
|
|
)
|
|
last_year_covered_time_offs |= sick_we.leave_id
|
|
elif not sick_we.leave_id:
|
|
last_year_sick_calendar_days += round_half_days(sick_we.duration / company_avg_hours_per_day)
|
|
|
|
pfa_calendar_sick_days_remaining = max(0, pfa_calendar_sick_days_remaining - last_year_sick_calendar_days)
|
|
|
|
for year, versions_by_month in version_by_date.items():
|
|
for month, version_by_day in versions_by_month.items():
|
|
days_by_version_counter = Counter(version_by_day.values())
|
|
if None in days_by_version_counter or month in full_unpaid_months:
|
|
continue
|
|
|
|
full_time_months += 1
|
|
days_in_month = monthrange(year, month)[1]
|
|
monthly_calendar_sick_time_off_days = 0
|
|
monthly_covered_time_offs = self.env['hr.leave']
|
|
for version, n_days in days_by_version_counter.items():
|
|
if is_PFA:
|
|
month_sick_work_entries = sick_work_entries.filtered(
|
|
lambda we: we.date.month == month and we.version_id == version
|
|
)
|
|
calendar_sick_time_off_days = 0
|
|
for sick_wes in month_sick_work_entries.grouped('date').values():
|
|
for sick_we in sick_wes:
|
|
if sick_we.leave_id and not sick_we.leave_id in covered_time_offs:
|
|
calendar_sick_time_off_days += _get_calendar_days(
|
|
sick_we.leave_id, date_from, date_to
|
|
)
|
|
covered_time_offs |= sick_we.leave_id
|
|
elif not sick_we.leave_id:
|
|
calendar_sick_time_off_days += round_half_days(sick_we.duration / company_avg_hours_per_day)
|
|
|
|
# Used to know how many days worked in month
|
|
if sick_we.leave_id and not sick_we.leave_id in monthly_covered_time_offs:
|
|
monthly_calendar_sick_time_off_days += _get_calendar_days(
|
|
sick_we.leave_id, date(year, month, 1), date(year, month, days_in_month)
|
|
)
|
|
monthly_covered_time_offs |= sick_we.leave_id
|
|
elif not sick_we.leave_id:
|
|
monthly_calendar_sick_time_off_days += round_half_days(sick_we.duration / company_avg_hours_per_day)
|
|
|
|
month_unpredictable_work_entries = unpredictable_work_entries.filtered(lambda we: we.date.month == month)
|
|
unpredictable_days = len(month_unpredictable_work_entries.mapped('date'))
|
|
|
|
max_sick_days_to_remove = min(monthly_calendar_sick_time_off_days, calendar_sick_time_off_days)
|
|
sick_days_to_remove = min(n_days, pfa_sick_calendar_days_to_defer + max_sick_days_to_remove)
|
|
if calendar_sick_time_off_days > sick_days_to_remove:
|
|
pfa_sick_calendar_days_to_defer += calendar_sick_time_off_days - sick_days_to_remove
|
|
else:
|
|
pfa_sick_calendar_days_to_defer -= max(0, sick_days_to_remove - max_sick_days_to_remove)
|
|
|
|
# Remove sick days
|
|
non_assimilated_days = max(0, sick_days_to_remove - pfa_calendar_sick_days_remaining)
|
|
pfa_calendar_sick_days_remaining = max(0, pfa_calendar_sick_days_remaining - sick_days_to_remove)
|
|
|
|
# Remove unpredictable days
|
|
non_assimilated_days += max(0, unpredictable_days - pfa_unpredictable_days_remaining)
|
|
pfa_unpredictable_days_remaining = max(0, pfa_unpredictable_days_remaining - unpredictable_days)
|
|
|
|
n_days -= non_assimilated_days
|
|
|
|
work_time_rate = version.work_time_rate
|
|
# Compute the real work time rate if partial incapacity. Partial incapacity should count in the work time rate in the PFA
|
|
if is_PFA and version.l10n_be_time_credit:
|
|
global_attendances = version.resource_calendar_id._get_global_attendances()
|
|
partial_incapacity_attendances = version.resource_calendar_id.attendance_ids.filtered_domain([
|
|
('display_type', '=', False),
|
|
('day_period', '!=', 'lunch'),
|
|
('work_entry_type_id.code', '=', 'LEAVE281')
|
|
])
|
|
hours_per_week_ref = version.resource_calendar_id.full_time_required_hours
|
|
hours_per_week = sum(att.hour_to - att.hour_from for att in global_attendances + partial_incapacity_attendances)
|
|
work_time_rate = hours_per_week / hours_per_week_ref if hours_per_week_ref else 1
|
|
|
|
payslip_amount += ((n_days / days_in_month) / 12) * (fte_basic * work_time_rate)
|
|
|
|
if is_PFA:
|
|
first_version_date = self.employee_id._get_first_version_date()
|
|
if first_version_date.year == self.date_from.year and first_version_date.month >= 7:
|
|
return 0, full_time_months
|
|
return payslip_amount, full_time_months
|
|
|
|
def _get_paid_amount_13th_month(self):
|
|
versions = self.employee_id.version_ids.filtered(lambda v: v.structure_type_id == self.struct_id.type_id)
|
|
first_version_date = self.employee_id._get_first_version_date(no_gap=False)
|
|
if not versions or not first_version_date:
|
|
return 0.0
|
|
|
|
if first_version_date.year == self.date_from.year and first_version_date.month > 6:
|
|
return 0.0
|
|
|
|
date_from = max(first_version_date, self.date_from + relativedelta(day=1, month=1))
|
|
date_to = self.date_to + relativedelta(day=31)
|
|
|
|
force_months = self.input_line_ids.filtered(lambda l: l.code == 'MONTHS')
|
|
if force_months:
|
|
n_months = force_months[0].amount
|
|
if n_months < 6:
|
|
return 0.0
|
|
fixed_salary = self.version_id._get_contract_wage() * n_months / 12
|
|
else:
|
|
fixed_salary, _ = self._compute_presence_prorated_fixed_wage(date_from, date_to, versions)
|
|
|
|
force_avg_variable_revenues = self.input_line_ids.filtered(lambda l: l.code == 'VARIABLE')
|
|
if force_avg_variable_revenues:
|
|
avg_variable_revenues = force_avg_variable_revenues[0].amount
|
|
else:
|
|
avg_variable_revenues = self.with_context(
|
|
variable_revenue_date_from=self.date_from
|
|
)._get_last_year_average_variable_revenues()
|
|
return fixed_salary + avg_variable_revenues
|
|
|
|
def _get_paid_amount_warrant(self):
|
|
self.ensure_one()
|
|
warrant_input_type = self.env.ref('l10n_be_hr_payroll.cp200_other_input_warrant')
|
|
return sum(self.input_line_ids.filtered(lambda a: a.input_type_id == warrant_input_type).mapped('amount'))
|
|
|
|
def _get_paid_double_holiday(self):
|
|
self.ensure_one()
|
|
versions = self.employee_id.version_ids.filtered(lambda v: v.structure_type_id == self.struct_id.type_id)
|
|
if not versions:
|
|
return 0.0
|
|
|
|
basic = self.version_id._get_contract_wage()
|
|
force_months = self.input_line_ids.filtered(lambda l: l.code == 'MONTHS')
|
|
|
|
year = self.date_from.year - 1
|
|
date_from = date(year, 1, 1)
|
|
date_to = date(year, 12, 31)
|
|
|
|
if force_months:
|
|
n_months = force_months[0].amount
|
|
fixed_salary = basic * n_months / 12
|
|
else:
|
|
fixed_salary, n_months = self._compute_presence_prorated_fixed_wage(date_from, date_to, versions)
|
|
if year == int(self.employee_id.first_contract_year_n1):
|
|
for line in self.employee_id.double_pay_line_n1_ids:
|
|
fixed_salary += basic * line.months_count * line.occupation_rate / 100 / 12
|
|
n_months += line.months_count
|
|
elif year == int(self.employee_id.first_contract_year_n):
|
|
for line in self.employee_id.double_pay_line_n_ids:
|
|
fixed_salary += basic * line.months_count * line.occupation_rate / 100 / 12
|
|
n_months += line.months_count
|
|
|
|
force_avg_variable_revenues = self.input_line_ids.filtered(lambda l: l.code == 'VARIABLE')
|
|
if force_avg_variable_revenues:
|
|
avg_variable_revenues = force_avg_variable_revenues[0].amount
|
|
else:
|
|
if not n_months:
|
|
avg_variable_revenues = 0
|
|
else:
|
|
avg_variable_revenues = self.with_context(
|
|
variable_revenue_date_from=self.date_from
|
|
)._get_last_year_average_variable_revenues()
|
|
return fixed_salary + avg_variable_revenues
|
|
|
|
def _get_paid_amount_cct90_bonus_plan(self):
|
|
self.ensure_one()
|
|
return self._get_input_line_amount('CCT90BONUSPLAN')
|
|
|
|
def _get_paid_amount(self):
|
|
self.ensure_one()
|
|
belgian_payslip = self.struct_id.country_id.code == "BE"
|
|
if belgian_payslip:
|
|
if self.struct_id.code == 'CP200THIRTEEN':
|
|
return self._get_paid_amount_13th_month()
|
|
if self.struct_id.code == 'CP200WARRANT':
|
|
return self._get_paid_amount_warrant()
|
|
if self.struct_id.code == 'CP200DOUBLE':
|
|
return self._get_paid_double_holiday()
|
|
if self.struct_id.code == 'CP200CCT90':
|
|
return self._get_paid_amount_cct90_bonus_plan()
|
|
return super()._get_paid_amount()
|
|
|
|
def _is_active_belgian_languages(self):
|
|
active_langs = self.env['res.lang'].with_context(active_test=True).search([]).mapped('code')
|
|
return any(l in active_langs for l in ["fr_BE", "fr_FR", "nl_BE", "nl_NL", "de_BE", "de_DE"])
|
|
|
|
def _get_sum_european_time_off_days(self, check=False):
|
|
self.ensure_one()
|
|
two_years_payslips = self.env['hr.payslip'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('date_to', '<=', date(self.date_from.year, 12, 31)),
|
|
('date_from', '>=', date(self.date_from.year - 2, 1, 1)),
|
|
('state', 'in', ['validated', 'paid']),
|
|
])
|
|
european_time_off_amount = two_years_payslips.filtered(lambda p: p.date_from.year < self.date_from.year)._get_worked_days_line_values(['LEAVE216'], ['amount'], True)['LEAVE216']['sum']['amount']
|
|
already_recovered_amount = two_years_payslips._get_line_values(['EU.LEAVE.DEDUC'], compute_sum=True)['EU.LEAVE.DEDUC']['sum']['total']
|
|
return european_time_off_amount + already_recovered_amount
|
|
|
|
def _is_invalid(self):
|
|
invalid = super()._is_invalid()
|
|
if not invalid and self._is_active_belgian_languages():
|
|
country = self.struct_id.country_id
|
|
if country.code == 'BE' and self.employee_id.lang not in ["fr_BE", "fr_FR", "nl_BE", "nl_NL", "de_BE", "de_DE"]:
|
|
return _('This document is a translation. This is not a legal document.')
|
|
return invalid
|
|
|
|
def _get_negative_net_input_type(self):
|
|
self.ensure_one()
|
|
if self.struct_id.code == 'CP200MONTHLY':
|
|
return self.env.ref('l10n_be_hr_payroll.input_negative_net')
|
|
return super()._get_negative_net_input_type()
|
|
|
|
def action_payslip_done(self):
|
|
if self._is_active_belgian_languages():
|
|
bad_language_slips = self.filtered(
|
|
lambda p: p.struct_id.country_id.code == "BE" and p.employee_id.lang not in ["fr_BE", "fr_FR", "nl_BE", "nl_NL", "de_BE", "de_DE"])
|
|
if bad_language_slips:
|
|
action = self.env['ir.actions.act_window'].\
|
|
_for_xml_id('l10n_be_hr_payroll.l10n_be_hr_payroll_employee_lang_wizard_action')
|
|
ctx = dict(self.env.context)
|
|
ctx.update({
|
|
'employee_ids': bad_language_slips.employee_id.ids,
|
|
'default_slip_ids': self.ids,
|
|
})
|
|
action['context'] = ctx
|
|
return action
|
|
return super().action_payslip_done()
|
|
|
|
def _get_pdf_reports(self):
|
|
res = super()._get_pdf_reports()
|
|
report_n = self.env.ref('l10n_be_hr_payroll.action_report_termination_holidays_n')
|
|
report_n1 = self.env.ref('l10n_be_hr_payroll.action_report_termination_holidays_n1')
|
|
for payslip in self:
|
|
if payslip.struct_id.code == 'CP200HOLN1':
|
|
res[report_n1] |= payslip
|
|
elif payslip.struct_id.code == 'CP200HOLN':
|
|
res[report_n] |= payslip
|
|
return res
|
|
|
|
def _get_data_files_to_update(self):
|
|
# Note: file order should be maintained
|
|
return super()._get_data_files_to_update() + [(
|
|
'l10n_be_hr_payroll', [
|
|
'data/hr_rule_parameters_data.xml',
|
|
])]
|
|
|
|
def _get_ffe_contribution_rate(self, worker_count):
|
|
# Fond de fermeture d'entreprise
|
|
# https://www.socialsecurity.be/employer/instructions/dmfa/fr/latest/instructions/special_contributions/other_specialcontributions/basiccontributions_closingcompanyfunds.html
|
|
self.ensure_one()
|
|
if self.company_id.l10n_be_ffe_employer_type == 'commercial':
|
|
if worker_count < 20:
|
|
rate = self.env['hr.rule.parameter']._get_parameter_from_code('l10n_be_ffe_commercial_rate_low', self.date_to)
|
|
else:
|
|
rate = self.env['hr.rule.parameter']._get_parameter_from_code('l10n_be_ffe_commercial_rate_high', self.date_to)
|
|
else:
|
|
rate = self.env['hr.rule.parameter']._get_parameter_from_code('l10n_be_ffe_noncommercial_rate', self.date_to)
|
|
return rate
|
|
|
|
def _get_be_termination_withholding_rate(self, localdict):
|
|
# See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument
|
|
self.ensure_one()
|
|
|
|
def find_rates(x, rates):
|
|
low_bound, high_bound = rates[0][0], rates[-1][1]
|
|
x = min(max(low_bound, x), high_bound)
|
|
for low, high, rate in rates:
|
|
if low <= x <= high:
|
|
return rate
|
|
|
|
inputs = localdict['inputs']
|
|
if 'ANNUAL_TAXABLE' not in inputs:
|
|
return 0
|
|
annual_taxable = inputs['ANNUAL_TAXABLE'].amount
|
|
|
|
# Note: Exoneration for children in charge is managed on the salary.rule for the amount
|
|
rates = self._rule_parameter('holiday_pay_pp_rates')
|
|
pp_rate = find_rates(annual_taxable, rates)
|
|
|
|
# Rate Reduction for children in charge
|
|
children = self.employee_id.dependent_children
|
|
children_reduction = self._rule_parameter('holiday_pay_pp_rate_reduction')
|
|
if children and annual_taxable <= children_reduction.get(children, children_reduction[5])[1]:
|
|
pp_rate *= (1 - children_reduction.get(children, children_reduction[5])[0] / 100.0)
|
|
return pp_rate
|
|
|
|
def _get_be_withholding_taxes(self, localdict):
|
|
self.ensure_one()
|
|
|
|
categories = localdict['categories']
|
|
|
|
def compute_basic_bareme(value):
|
|
rates = self._rule_parameter('basic_bareme_rates')
|
|
rates = [(limit or float('inf'), rate) for limit, rate in rates] # float('inf') because limit equals None for last level
|
|
rates = sorted(rates)
|
|
|
|
basic_bareme = 0
|
|
previous_limit = 0
|
|
for limit, rate in rates:
|
|
basic_bareme += max(min(value, limit) - previous_limit, 0) * rate
|
|
previous_limit = limit
|
|
return float_round(basic_bareme, precision_rounding=0.01)
|
|
|
|
def convert_to_month(value):
|
|
return float_round(value / 12.0, precision_rounding=0.01, rounding_method='DOWN')
|
|
|
|
version = self.version_id
|
|
# PART 1: Withholding tax amount computation
|
|
withholding_tax_amount = 0.0
|
|
|
|
taxable_amount = categories['GROSS'] # Base imposable
|
|
|
|
if self.date_from.year < 2023:
|
|
lower_bound = taxable_amount - taxable_amount % 15
|
|
else:
|
|
lower_bound = taxable_amount
|
|
|
|
# yearly_gross_revenue = Revenu Annuel Brut
|
|
yearly_gross_revenue = lower_bound * 12.0
|
|
|
|
# yearly_net_taxable_amount = Revenu Annuel Net Imposable
|
|
if yearly_gross_revenue <= self._rule_parameter('yearly_gross_revenue_bound_expense'):
|
|
yearly_net_taxable_revenue = yearly_gross_revenue * (1.0 - 0.3)
|
|
else:
|
|
yearly_net_taxable_revenue = yearly_gross_revenue - self._rule_parameter('expense_deduction')
|
|
|
|
# BAREME III: Non resident
|
|
if version.is_non_resident:
|
|
basic_bareme = compute_basic_bareme(yearly_net_taxable_revenue)
|
|
withholding_tax_amount = convert_to_month(basic_bareme)
|
|
else:
|
|
# BAREME I: Isolated or spouse with income
|
|
if version._l10n_be_is_isolated() or (version._l10n_be_is_coupled() and version.spouse_fiscal_status != 'without_income'):
|
|
basic_bareme = max(compute_basic_bareme(yearly_net_taxable_revenue) - self._rule_parameter('deduct_single_with_income'), 0.0)
|
|
withholding_tax_amount = convert_to_month(basic_bareme)
|
|
|
|
# BAREME II: spouse without income
|
|
if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'without_income':
|
|
yearly_net_taxable_revenue_for_spouse = min(yearly_net_taxable_revenue * 0.3, self._rule_parameter('max_spouse_income'))
|
|
basic_bareme_1 = compute_basic_bareme(yearly_net_taxable_revenue_for_spouse)
|
|
basic_bareme_2 = compute_basic_bareme(yearly_net_taxable_revenue - yearly_net_taxable_revenue_for_spouse)
|
|
withholding_tax_amount = convert_to_month(max(basic_bareme_1 + basic_bareme_2 - 2 * self._rule_parameter('deduct_single_with_income'), 0))
|
|
|
|
# Reduction for other family charges
|
|
if (version.children and version.dependent_children) or (version.other_dependent_people and (version.dependent_seniors or version.dependent_juniors)):
|
|
if version._l10n_be_is_isolated() or (version._l10n_be_is_coupled() and version.spouse_fiscal_status != 'without_income'):
|
|
if version._l10n_be_is_isolated() and version.dependent_children:
|
|
withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction')
|
|
if version.disabled:
|
|
withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction')
|
|
if version.other_dependent_people and version.dependent_seniors:
|
|
withholding_tax_amount -= self._rule_parameter('dependent_senior_deduction') * version.dependent_seniors
|
|
if version.other_dependent_people and version.dependent_juniors:
|
|
withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') * version.dependent_juniors
|
|
if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'low_income':
|
|
withholding_tax_amount -= self._rule_parameter('spouse_low_income_deduction')
|
|
if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'low_pension':
|
|
withholding_tax_amount -= self._rule_parameter('spouse_other_income_deduction')
|
|
if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'without_income':
|
|
if version.disabled:
|
|
withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction')
|
|
if version.disabled_spouse_bool:
|
|
withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction')
|
|
if version.other_dependent_people and version.dependent_seniors:
|
|
withholding_tax_amount -= self._rule_parameter('dependent_senior_deduction') * version.dependent_seniors
|
|
if version.other_dependent_people and version.dependent_juniors:
|
|
withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') * version.dependent_juniors
|
|
|
|
# Child Allowances
|
|
n_children = version.dependent_children
|
|
if n_children > 0:
|
|
children_deduction = self._rule_parameter('dependent_basic_children_deduction')
|
|
if n_children <= 8:
|
|
withholding_tax_amount -= children_deduction.get(n_children, 0.0)
|
|
if n_children > 8:
|
|
withholding_tax_amount -= children_deduction.get(8, 0.0) + (n_children - 8) * self._rule_parameter('dependent_children_deduction')
|
|
|
|
return - max(withholding_tax_amount, 0.0)
|
|
|
|
def _get_be_special_social_cotisations(self, localdict):
|
|
self.ensure_one()
|
|
|
|
def find_rate(x, rates):
|
|
for low, high, rate, basis, min_amount, max_amount in rates:
|
|
if low <= x <= high:
|
|
return low, high, rate, basis, min_amount, max_amount
|
|
return 0, 0, 0, 0, 0, 0
|
|
|
|
categories = localdict['categories']
|
|
version = self.version_id
|
|
employee = version.employee_id
|
|
wage = categories['BASIC']
|
|
if not wage or version.is_non_resident:
|
|
return 0.0
|
|
|
|
if version._l10n_be_is_isolated() or (version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'without_income'):
|
|
rates = self._rule_parameter('cp200_monss_isolated')
|
|
if not rates:
|
|
rates = [
|
|
(0.00, 1945.38, 0.00, 0.00, 0.00, 0.00),
|
|
(1945.39, 2190.18, 0.076, 0.00, 0.00, 18.60),
|
|
(2190.19, 6038.82, 0.011, 18.60, 0.00, 60.94),
|
|
(6038.83, 999999999.00, 1.000, 60.94, 0.00, 60.94),
|
|
]
|
|
low, dummy, rate, basis, min_amount, max_amount = find_rate(wage, rates)
|
|
return -min(max(basis + (wage - low + 0.01) * rate, min_amount), max_amount)
|
|
|
|
if version._l10n_be_is_coupled() and version.spouse_fiscal_status != 'without_income':
|
|
rates = self._rule_parameter('cp200_monss_couple')
|
|
if not rates:
|
|
rates = [
|
|
(0.00, 1095.09, 0.00, 0.00, 0.00, 0.00),
|
|
(1095.10, 1945.38, 0.00, 9.30, 9.30, 9.30),
|
|
(1945.39, 2190.18, 0.076, 0.00, 9.30, 18.60),
|
|
(2190.19, 6038.82, 0.011, 18.60, 0.00, 51.64),
|
|
(6038.83, 999999999.00, 1.000, 51.64, 51.64, 51.64),
|
|
]
|
|
low, dummy, rate, basis, min_amount, max_amount = find_rate(wage, rates)
|
|
if isinstance(max_amount, tuple):
|
|
if version.spouse_fiscal_status in ['high_income', 'low_income']:
|
|
# conjoint avec revenus professionnels
|
|
max_amount = max_amount[0]
|
|
else:
|
|
# conjoint sans revenus professionnels
|
|
max_amount = max_amount[1]
|
|
return -min(max(basis + (wage - low + 0.01) * rate, min_amount), max_amount)
|
|
return 0.0
|
|
|
|
def _get_be_ip(self, localdict):
|
|
self.ensure_one()
|
|
contract = self.version_id
|
|
if not contract.ip:
|
|
return 0.0
|
|
return self._get_paid_amount() * contract.ip_wage_rate / 100.0
|
|
|
|
def _get_be_ip_deduction(self, localdict):
|
|
self.ensure_one()
|
|
tax_rate = 0.15
|
|
ip_amount = self._get_be_ip(localdict)
|
|
if not ip_amount:
|
|
return 0.0
|
|
ip_deduction_bracket_1 = self._rule_parameter('ip_deduction_bracket_1')
|
|
ip_deduction_bracket_2 = self._rule_parameter('ip_deduction_bracket_2')
|
|
if 0.0 <= ip_amount <= ip_deduction_bracket_1:
|
|
tax_rate = tax_rate / 2.0
|
|
elif ip_deduction_bracket_1 < ip_amount <= ip_deduction_bracket_2:
|
|
tax_rate = tax_rate * 3.0 / 4.0
|
|
return - min(ip_amount * tax_rate, 11745)
|
|
|
|
def _get_employment_bonus_employees_volet_A(self, localdict):
|
|
categories = localdict['categories']
|
|
result_rules = localdict['result_rules']
|
|
if not self.worked_days_line_ids and not self.env.context.get('salary_simulation'):
|
|
return 0
|
|
|
|
# S = (W / H) * U
|
|
# W = salaire brut
|
|
# H = le nombre d'heures de travail déclarées avec un code prestations 1, 3, 4, 5 et 20;
|
|
# U = le nombre maximum d'heures de prestations pour le mois concerné dans le régime de travail concerné
|
|
if self.env.context.get('salary_simulation'):
|
|
paid_hours = 1
|
|
total_hours = 1
|
|
else:
|
|
worked_days = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE300', 'LEAVE301', 'MEDIC01'])
|
|
paid_hours = sum(worked_days.filtered(lambda wd: wd.amount).mapped('number_of_hours')) # H
|
|
total_hours = sum(worked_days.mapped('number_of_hours')) # U
|
|
|
|
# 1. - Détermination du salaire mensuel de référence (S)
|
|
basic = categories['BRUT'] - result_rules['HolPayRecN']['total'] - result_rules['HolPayRecN1']['total']
|
|
salary = float_round(basic / paid_hours, precision_digits=2) * total_hours # S = (W/H) x U
|
|
|
|
# 2. - Détermination du montant de base de la réduction (R)
|
|
bonus_basic_amount_volet_A = self._rule_parameter('work_bonus_basic_amount_volet_A')
|
|
wage_lower_bound = self._rule_parameter('work_bonus_reference_wage_low')
|
|
wage_middle_bound = self._rule_parameter('l10n_be_work_bonus_reference_wage_middle')
|
|
wage_higher_bound = self._rule_parameter('work_bonus_reference_wage_high')
|
|
if salary <= wage_lower_bound:
|
|
result = bonus_basic_amount_volet_A
|
|
elif salary <= wage_middle_bound:
|
|
result = bonus_basic_amount_volet_A
|
|
elif salary <= wage_higher_bound:
|
|
coeff = self._rule_parameter('work_bonus_coeff')
|
|
result = bonus_basic_amount_volet_A - (coeff * (salary - wage_middle_bound))
|
|
else:
|
|
result = 0
|
|
|
|
# 3. - Détermination du montant de la réduction (P)
|
|
result = float_round(paid_hours / total_hours, precision_digits=2) * result # P = (H/U) x R
|
|
|
|
return result
|
|
|
|
def _get_employment_bonus_employees_volet_B(self, localdict):
|
|
categories = localdict['categories']
|
|
result_rules = localdict['result_rules']
|
|
if not self.worked_days_line_ids and not self.env.context.get('salary_simulation'):
|
|
return 0
|
|
|
|
# S = (W / H) * U
|
|
# W = salaire brut
|
|
# H = le nombre d'heures de travail déclarées avec un code prestations 1, 3, 4, 5 et 20;
|
|
# U = le nombre maximum d'heures de prestations pour le mois concerné dans le régime de travail concerné
|
|
if self.env.context.get('salary_simulation'):
|
|
paid_hours = 1
|
|
total_hours = 1
|
|
else:
|
|
worked_days = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE300', 'LEAVE301', 'MEDIC01'])
|
|
paid_hours = sum(worked_days.filtered(lambda wd: wd.amount).mapped('number_of_hours')) # H
|
|
total_hours = sum(worked_days.mapped('number_of_hours')) # U
|
|
|
|
# 1. - Détermination du salaire mensuel de référence (S)
|
|
basic = categories['BRUT'] - result_rules['HolPayRecN']['total'] - result_rules['HolPayRecN1']['total']
|
|
salary = float_round(basic / paid_hours, precision_digits=2) * total_hours # S = (W/H) x U
|
|
|
|
# 2. - Détermination du montant de base de la réduction (R)
|
|
bonus_basic_amount = self._rule_parameter('work_bonus_basic_amount')
|
|
wage_lower_bound = self._rule_parameter('work_bonus_reference_wage_low')
|
|
wage_middle_bound = self._rule_parameter('l10n_be_work_bonus_reference_wage_middle')
|
|
wage_higher_bound = self._rule_parameter('work_bonus_reference_wage_high')
|
|
if salary <= wage_lower_bound:
|
|
result = bonus_basic_amount
|
|
elif salary <= wage_middle_bound:
|
|
coeff = self._rule_parameter('l10n_be_work_bonus_coeff_low')
|
|
result = bonus_basic_amount - (coeff * (salary - wage_lower_bound))
|
|
elif salary <= wage_higher_bound:
|
|
result = 0
|
|
else:
|
|
result = 0
|
|
|
|
# 3. - Détermination du montant de la réduction (P)
|
|
result = float_round(paid_hours / total_hours, precision_digits=2) * result # P = (H/U) x R
|
|
|
|
return result
|
|
|
|
# ref: https://www.socialsecurity.be/employer/instructions/dmfa/fr/latest/instructions/deductions/workers_reductions/workbonus.html
|
|
def _get_employment_bonus_employees(self, localdict):
|
|
self.ensure_one()
|
|
categories = localdict['categories']
|
|
result_rules = localdict['result_rules']
|
|
if self.date_from >= date(2024, 4, 1):
|
|
bonus_volet_A = localdict['result_rules']['EmpBonus.A']['total']
|
|
bonus_volet_B = localdict['result_rules']['EmpBonus.B']['total']
|
|
result = bonus_volet_A + bonus_volet_B
|
|
return min(result, -categories['ONSS'])
|
|
|
|
bonus_basic_amount = self._rule_parameter('work_bonus_basic_amount')
|
|
wage_lower_bound = self._rule_parameter('work_bonus_reference_wage_low')
|
|
if not self.worked_days_line_ids and not self.env.context.get('salary_simulation'):
|
|
return 0
|
|
|
|
# S = (W / H) * U
|
|
# W = salaire brut
|
|
# H = le nombre d'heures de travail déclarées avec un code prestations 1, 3, 4, 5 et 20;
|
|
# U = le nombre maximum d'heures de prestations pour le mois concerné dans le régime de travail concerné
|
|
if self.env.context.get('salary_simulation'):
|
|
paid_hours = 1
|
|
total_hours = 1
|
|
else:
|
|
worked_days = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE300', 'LEAVE301', 'MEDIC01'])
|
|
paid_hours = sum(worked_days.filtered(lambda wd: wd.amount).mapped('number_of_hours')) # H
|
|
total_hours = sum(worked_days.mapped('number_of_hours')) # U
|
|
|
|
# 1. - Détermination du salaire mensuel de référence (S)
|
|
basic = categories['BRUT'] - result_rules['HolPayRecN']['total'] - result_rules['HolPayRecN1']['total']
|
|
salary = float_round(basic / paid_hours, precision_digits=2) * total_hours # S = (W/H) x U
|
|
|
|
# 2. - Détermination du montant de base de la réduction (R)
|
|
if self.date_from < date(2023, 7, 1):
|
|
if salary <= wage_lower_bound:
|
|
result = bonus_basic_amount
|
|
elif salary <= self._rule_parameter('work_bonus_reference_wage_high'):
|
|
coeff = self._rule_parameter('work_bonus_coeff')
|
|
result = bonus_basic_amount - (coeff * (salary - wage_lower_bound))
|
|
else:
|
|
result = 0
|
|
else:
|
|
if salary <= wage_lower_bound:
|
|
result = bonus_basic_amount
|
|
elif salary <= self._rule_parameter('l10n_be_work_bonus_reference_wage_middle'):
|
|
coeff = self._rule_parameter('l10n_be_work_bonus_coeff_low')
|
|
result = bonus_basic_amount - (coeff * (salary - wage_lower_bound))
|
|
elif salary <= self._rule_parameter('work_bonus_reference_wage_high'):
|
|
coeff = self._rule_parameter('work_bonus_coeff')
|
|
result = bonus_basic_amount - (coeff * (salary - wage_lower_bound))
|
|
else:
|
|
result = 0
|
|
|
|
# 3. - Détermination du montant de la réduction (P)
|
|
result = float_round(paid_hours / total_hours, precision_digits=2) * result # P = (H/U) x R
|
|
|
|
return min(result, -categories['ONSS'])
|
|
|
|
def _get_withholding_taxes_after_child_allowances(self, rates, gross, apply_reduction=True):
|
|
|
|
def find_rates(x, rates):
|
|
low_bound, high_bound = rates[0][0], rates[-1][1]
|
|
x = min(max(low_bound, x), high_bound)
|
|
for low, high, rate in rates:
|
|
if low <= x <= high:
|
|
return rate / 100.0
|
|
|
|
children_exoneration = self._rule_parameter('holiday_pay_pp_exoneration')
|
|
children_reduction = self._rule_parameter('holiday_pay_pp_rate_reduction')
|
|
|
|
version = self.version_id
|
|
|
|
monthly_revenue = version._get_contract_wage()
|
|
# Count ANT in yearly remuneration
|
|
if version.internet:
|
|
monthly_revenue += 5.0
|
|
if version.mobile and not version.internet:
|
|
monthly_revenue += 4.0 + 5.0
|
|
if version.mobile and version.internet:
|
|
monthly_revenue += 4.0
|
|
if version.has_laptop:
|
|
monthly_revenue += 7.0
|
|
|
|
yearly_revenue = monthly_revenue * (1 - 0.1307) * 12.0
|
|
|
|
if version.transport_mode_car:
|
|
if 'vehicle_id' in self:
|
|
yearly_revenue += self.vehicle_id._get_car_atn(date=self.date_from) * 12.0
|
|
else:
|
|
yearly_revenue += version.car_atn * 12.0
|
|
|
|
# Exoneration
|
|
children = version.dependent_children
|
|
if children > 0 and yearly_revenue <= children_exoneration.get(children, children_exoneration[12]):
|
|
yearly_revenue -= children_exoneration.get(children, children_exoneration[12]) - yearly_revenue
|
|
yearly_revenue = max(yearly_revenue, 0)
|
|
|
|
withholding_tax_amount = gross * find_rates(yearly_revenue, rates)
|
|
# Reduction
|
|
if (apply_reduction and
|
|
children > 0 and
|
|
yearly_revenue <= children_reduction.get(children, children_reduction[5])[1]
|
|
):
|
|
withholding_tax_amount *= (1 - children_reduction.get(children, children_reduction[5])[0] / 100.0)
|
|
|
|
return - withholding_tax_amount
|
|
|
|
def _get_be_double_holiday_withholding_taxes(self, localdict):
|
|
self.ensure_one()
|
|
# See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument
|
|
|
|
rates = self._rule_parameter('holiday_pay_pp_rates')
|
|
|
|
categories = localdict['categories']
|
|
if self.struct_id.code == "CP200DOUBLE":
|
|
gross = categories['GROSS']
|
|
elif self.struct_id.code == "CP200MONTHLY":
|
|
gross = categories['DDPG']
|
|
|
|
return self._get_withholding_taxes_after_child_allowances(rates, gross)
|
|
|
|
def _get_thirteen_month_withholding_taxes(self, localdict):
|
|
self.ensure_one()
|
|
# See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument
|
|
|
|
rates = self._rule_parameter('exceptional_allowances_pp_rates')
|
|
categories = localdict['categories']
|
|
gross = categories['GROSS']
|
|
|
|
return self._get_withholding_taxes_after_child_allowances(rates, gross)
|
|
|
|
def _get_termination_fees_withholding_taxes(self, localdict):
|
|
# See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument
|
|
if self.date_from.year >= 2024:
|
|
self.ensure_one()
|
|
|
|
rates = self._rule_parameter('termination_fees_pp_rates')
|
|
categories = localdict['categories']
|
|
gross = categories['GROSS']
|
|
|
|
return self._get_withholding_taxes_after_child_allowances(rates, gross, apply_reduction=False)
|
|
|
|
else:
|
|
return self._get_be_withholding_taxes(localdict)
|
|
|
|
def _get_withholding_reduction(self, localdict):
|
|
self.ensure_one()
|
|
categories = localdict['categories']
|
|
if categories['EmpBonus']:
|
|
bonus_volet_A_rate = self.env['hr.rule.parameter']._get_parameter_from_code('work_bonus_volet_A_rate', date=self.date_to, raise_if_not_found=False) or 0.3314
|
|
if self.date_from >= date(2024, 4, 1):
|
|
bonus_volet_A = localdict['result_rules']['EmpBonus.A']['total']
|
|
bonus_volet_B = localdict['result_rules']['EmpBonus.B']['total']
|
|
bonus_volet_B_rate = self.env['hr.rule.parameter']._get_parameter_from_code('work_bonus_volet_B_rate', date=self.date_to, raise_if_not_found=False) or 0.5254
|
|
reduction = bonus_volet_A * bonus_volet_A_rate + bonus_volet_B * bonus_volet_B_rate
|
|
else:
|
|
reduction = categories['EmpBonus'] * bonus_volet_A_rate
|
|
return min(abs(categories['PP']), reduction)
|
|
return 0.0
|
|
|
|
def _get_impulsion_plan_amount(self, localdict):
|
|
self.ensure_one()
|
|
start = self.employee_id.contract_date_start
|
|
end = self.date_to
|
|
number_of_months = (end.year - start.year) * 12 + (end.month - start.month)
|
|
numerator = sum(wd.number_of_hours for wd in self.worked_days_line_ids if wd.amount > 0)
|
|
denominator = 4 * self.version_id.resource_calendar_id.hours_per_week
|
|
coefficient = numerator / denominator
|
|
if self.version_id.l10n_be_impulsion_plan == '25yo':
|
|
if 0 <= number_of_months <= 23:
|
|
theorical_amount = 500.0
|
|
elif 24 <= number_of_months <= 29:
|
|
theorical_amount = 250.0
|
|
elif 30 <= number_of_months <= 35:
|
|
theorical_amount = 125.0
|
|
else:
|
|
theorical_amount = 0
|
|
return min(theorical_amount, theorical_amount * coefficient)
|
|
if self.version_id.l10n_be_impulsion_plan == '12mo':
|
|
if 0 <= number_of_months <= 11:
|
|
theorical_amount = 500.0
|
|
elif 12 <= number_of_months <= 17:
|
|
theorical_amount = 250.0
|
|
elif 18 <= number_of_months <= 23:
|
|
theorical_amount = 125.0
|
|
else:
|
|
theorical_amount = 0
|
|
return min(theorical_amount, theorical_amount * coefficient)
|
|
return 0
|
|
|
|
def _get_onss_restructuring(self, localdict):
|
|
self.ensure_one()
|
|
# Source: https://www.onem.be/fr/documentation/feuille-info/t115
|
|
|
|
# 1. Grant condition
|
|
# A worker who has been made redundant following a restructuring benefits from a reduction in his personal contributions under certain conditions:
|
|
# - The engagement must take place during the validity period of the reduction card. The reduction card is valid for 6 months, calculated from date to date, following the termination of the employment contract.
|
|
# - The gross monthly reference salary does not exceed
|
|
# o 3.071.90: if the worker is under 30 years of age at the time of entry into service
|
|
# o 4,504.93: if the worker is at least 30 years old at the time of entry into service
|
|
# 2. Amount of reduction
|
|
# Lump sum reduction of € 133.33 per month (full time - full month) in personal social security contributions.
|
|
# If the worker does not work full time for a full month or if he works part time, this amount is reduced proportionally.
|
|
|
|
# So the reduction is:
|
|
# 1. Full-time worker: P = (J / D) x 133.33
|
|
# - Full time with full one month benefits: € 133.33
|
|
|
|
# Example the worker entered service on 02/01/2021 and worked the whole month
|
|
# - Full time with incomplete services: P = (J / D) x 133.33
|
|
# Example: the worker entered service on February 15 -> (10/20) x 133.33 = € 66.665
|
|
# P = amount of reduction
|
|
# J = the number of worker's days declared with a benefit code 1, 3, 4, 5 and 20 .;
|
|
# D = the maximum number of days of benefits for the month concerned in the work scheme concerned.
|
|
|
|
# 2. Part-time worker: P = (H / U) x 133.33
|
|
# Example: the worker starts 02/01/2021 and works 19 hours a week.
|
|
# (76/152) x 133.33 = € 66.665
|
|
# Example: the worker starts 02/15/2021 and works 19 hours a week.
|
|
# (38/155) x 133.33 = 33.335 €
|
|
|
|
# P = amount of reduction
|
|
# H = the number of working hours declared with a service code 1, 3, 4, 5 and 20;
|
|
# U = the number of monthly hours corresponding to D.
|
|
|
|
# 3. Duration of this reduction
|
|
# The benefit applies to all periods of occupation that fall within the period that:
|
|
# starts to run on the day you start your first occupation during the validity period of the restructuring reduction card;
|
|
# and which ends on the last day of the second quarter following the start date of this first occupation.
|
|
# 4. Formalities to be completed
|
|
# The employer deducts the lump sum from the normal amount of personal contributions when paying the remuneration.
|
|
# The ONEM communicates to the ONSS the data concerning the identification of the worker and the validity date of the card.
|
|
|
|
# 5. Point of attention
|
|
# If the worker also benefits from a reduction in his personal contributions for low wages, the cumulation between this reduction and that for restructuring cannot exceed the total amount of personal contributions due.
|
|
|
|
# If this is the case, we must first reduce the restructuring reduction.
|
|
|
|
# Example:
|
|
# - personal contributions = 200 €
|
|
# - restructuring reduction = € 133.33
|
|
# - low salary reduction = 100 €
|
|
|
|
# The total amount of reductions exceeds the contributions due. We must therefore first reduce the restructuring reduction and then the balance of the low wage reduction.
|
|
if not self.worked_days_line_ids:
|
|
return 0
|
|
|
|
employee = self.version_id.employee_id
|
|
contract_date_start = employee.contract_date_start
|
|
birthdate = employee.birthday
|
|
age = relativedelta(contract_date_start, birthdate).years
|
|
if age < 30:
|
|
threshold = self._rule_parameter('onss_restructuring_before_30')
|
|
else:
|
|
threshold = self._rule_parameter('onss_restructuring_after_30')
|
|
|
|
salary = self.paid_amount
|
|
if salary > threshold:
|
|
return 0
|
|
|
|
amount = self._rule_parameter('onss_restructuring_amount')
|
|
|
|
paid_hours = sum(self.worked_days_line_ids.filtered(lambda wd: wd.amount).mapped('number_of_hours'))
|
|
total_hours = sum(self.worked_days_line_ids.mapped('number_of_hours'))
|
|
ratio = paid_hours / total_hours if total_hours else 0
|
|
|
|
start = contract_date_start
|
|
end = self.date_to
|
|
number_of_months = (end.year - start.year) * 12 + (end.month - start.month)
|
|
if 0 <= number_of_months <= 6:
|
|
return amount * ratio
|
|
return 0
|
|
|
|
def _get_representation_fees_threshold(self, localdict):
|
|
return self._rule_parameter('cp200_representation_fees_threshold')
|
|
|
|
def _get_representation_fees(self, localdict):
|
|
self.ensure_one()
|
|
categories = localdict['categories']
|
|
worked_days = localdict['worked_days']
|
|
# Representation fees aren't paid if there's no basic pay or no time worked
|
|
if categories['BASIC'] and (
|
|
not all(day.work_entry_type_id.is_leave for day in worked_days.values())
|
|
or self.env.context.get('salary_simulation')
|
|
):
|
|
version = self.version_id
|
|
calendar = version.resource_calendar_id
|
|
days_per_week = calendar._get_days_per_week()
|
|
work_time_rate = version.resource_calendar_id.work_time_rate
|
|
|
|
threshold = 0 if ('OUT' in worked_days and worked_days['OUT'].number_of_hours) else self._get_representation_fees_threshold(localdict)
|
|
if days_per_week and self.env.context.get('salary_simulation_full_time'):
|
|
result = version.representation_fees
|
|
elif days_per_week and version.representation_fees > threshold:
|
|
# Only part of the representation costs are pro-rated because certain costs are fully
|
|
# covered for the company (teleworking costs, mobile phone, internet, etc., namely (for 2021):
|
|
# - 144.31 € (Tax, since 2021 - coronavirus)
|
|
# - 30 € (internet)
|
|
# - 25 € (phone)
|
|
# - 80 € (car management fees)
|
|
# = Total € 279.31
|
|
# Legally, they are not prorated according to the occupancy fraction.
|
|
# In summary, those who select amounts of for example 150 € and 250 €, have nothing pro-rated
|
|
# because the amounts are covered in an irreducible way.
|
|
# For those who have selected the maximum of 399 €, there is therefore only the share of
|
|
# +-120 € of representation expenses which is then subject to prorating.
|
|
|
|
# Credit time, but with only half days (otherwise it's taken into account)
|
|
is_credit_time_only_half_days = version.l10n_be_time_credit and work_time_rate and \
|
|
work_time_rate < 100 and (days_per_week == 5 or not self.representation_fees_missing_days)
|
|
# Contractual part time
|
|
is_contractual_part_time = not version.l10n_be_time_credit and work_time_rate < 100
|
|
if is_credit_time_only_half_days or is_contractual_part_time:
|
|
total_amount = threshold + (version.representation_fees - threshold) * work_time_rate / 100
|
|
else:
|
|
total_amount = version.representation_fees
|
|
|
|
if total_amount > threshold:
|
|
daily_amount = (total_amount - threshold) * 3 / 13 / days_per_week
|
|
result = max(0, total_amount - daily_amount * self.representation_fees_missing_days)
|
|
elif days_per_week:
|
|
result = version.representation_fees
|
|
else:
|
|
result = 0
|
|
else:
|
|
result = 0
|
|
return float_round(result, precision_digits=2)
|
|
|
|
def _get_serious_representation_fees(self, localdict):
|
|
self.ensure_one()
|
|
return min(self._get_representation_fees(localdict), self._get_representation_fees_threshold(localdict))
|
|
|
|
def _get_volatile_representation_fees(self, localdict):
|
|
self.ensure_one()
|
|
return max(self._get_representation_fees(localdict) - self._get_representation_fees_threshold(localdict), 0)
|
|
|
|
def _get_holiday_pay_recovery(self, localdict, recovery_type):
|
|
"""
|
|
See: https://www.socialsecurity.be/employer/instructions/dmfa/fr/latest/intermediates#intermediate_row_196b32c7-9d98-4233-805d-ca9bf123ff48
|
|
|
|
When an employee changes employer, he receives the termination pay and a vacation certificate
|
|
stating his vacation rights. When he subsequently takes vacation with his new employer, the latter
|
|
must, when paying the simple vacation pay, take into account the termination pay that the former
|
|
employer has already paid.
|
|
|
|
From an exchange of letters with the SPF ETCS and the Inspectorate responsible for the control of
|
|
social laws, it turned out that when calculating the simple vacation pay, the new employer must
|
|
deduct the exit pay based on the number of vacation days taken. The rule in the ONSS instructions
|
|
according to which the new employer must take into account the exit vacation pay only once when the
|
|
employee takes his main vacation is abolished.
|
|
|
|
When the salary of an employee with his new employer is higher than the salary he had with his
|
|
previous employer, his new employer will have, each time he takes vacation days, to make a
|
|
calculation to supplement the nest egg. exit from these days up to the amount of the simple vacation
|
|
pay to which the worker is entitled.
|
|
|
|
Concretely:
|
|
|
|
2020 vacation certificate (full year):
|
|
- simple allowance 1,917.50 EUR
|
|
- this amounts to 1917.50 / 20 EUR = 95.875 EUR per day of vacation
|
|
- holidays 2021, for example when taking 5 days in April 2021
|
|
- monthly salary with the new employer: 3000.00 EUR / month
|
|
- simple nest egg:
|
|
- remuneration code 12: 5/20 x 1917.50 = 479.38 EUR
|
|
- remuneration code 1: (5/22 x 3000.00) - 479.38 = 202.44 EUR
|
|
- ordinary days for the month of April:
|
|
- remuneration code 1: 17/22 x 3000.00 = 2318.18 EUR
|
|
- The examples included in the ONSS instructions will be adapted in the next publication.
|
|
"""
|
|
self.ensure_one()
|
|
worked_days = localdict['worked_days']
|
|
if 'LEAVE120' not in worked_days or not worked_days['LEAVE120'].amount:
|
|
return 0
|
|
employee = self.employee_id
|
|
number_of_days = employee['l10n_be_holiday_pay_number_of_days_' + recovery_type]
|
|
all_payslips_during_civil_year = self.env['hr.payslip'].search([
|
|
('employee_id', '=', employee.id),
|
|
('date_from', '>=', date(self.date_from.year, 1, 1)),
|
|
('date_to', '<=', date(self.date_from.year, 12, 31)),
|
|
('state', 'in', ['validated', 'paid']),
|
|
])
|
|
paid_leave_days = all_payslips_during_civil_year._get_worked_days_line_values(['LEAVE120'], ['number_of_days'], True)['LEAVE120']['sum']['number_of_days']
|
|
remaining_day = number_of_days - paid_leave_days
|
|
if remaining_day <= 0:
|
|
return 0
|
|
if self.wage_type == 'hourly':
|
|
employee_hourly_cost = self.version_id.hourly_wage
|
|
else:
|
|
if self.date_from.year < 2024:
|
|
employee_hourly_cost = self.version_id.contract_wage / self.sum_worked_hours
|
|
else:
|
|
employee_hourly_cost = self.version_id.contract_wage * 3 / 13 / self.version_id.resource_calendar_id.hours_per_week
|
|
employee_hours_per_day = self.version_id.resource_calendar_id.hours_per_day
|
|
remaining_day_amount = min(remaining_day, number_of_days) * employee_hourly_cost * employee_hours_per_day
|
|
days_to_recover = employee['l10n_be_holiday_pay_to_recover_' + recovery_type]
|
|
max_amount_to_recover = min(days_to_recover, employee_hourly_cost * number_of_days * employee_hours_per_day)
|
|
paid_leave_data = self._get_worked_days_line_values(['LEAVE120'], ['amount', 'number_of_hours'], True)['LEAVE120']['sum']
|
|
holiday_amount = min(paid_leave_data['amount'], employee_hourly_cost * paid_leave_data['number_of_hours'])
|
|
remaining_amount = max(0, max_amount_to_recover - employee['l10n_be_holiday_pay_recovered_' + recovery_type])
|
|
return - min(remaining_amount, remaining_day_amount, holiday_amount)
|
|
|
|
def _get_holiday_pay_recovery_n(self, localdict):
|
|
return self._get_holiday_pay_recovery(localdict, 'n')
|
|
|
|
def _get_holiday_pay_recovery_n1(self, localdict):
|
|
return self._get_holiday_pay_recovery(localdict, 'n1')
|
|
|
|
def _get_termination_n_basic_double(self, localdict):
|
|
self.ensure_one()
|
|
inputs = localdict['inputs']
|
|
result_qty = 1
|
|
result_rate = 6.8
|
|
result = inputs['GROSS_REF'].amount if 'GROSS_REF' in inputs else 0
|
|
result_name = inputs['GROSS_REF'].name if 'GROSS_REF' in inputs else None
|
|
date_from = self.date_from
|
|
if self.struct_id.code == "CP200HOLN1":
|
|
existing_double_pay = self.env['hr.payslip'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('state', 'in', ['validated', 'paid']),
|
|
('struct_id', '=', self.env.ref('l10n_be_hr_payroll.hr_payroll_structure_cp200_double_holiday').id),
|
|
('date_from', '>=', date(date_from.year, 1, 1)),
|
|
('date_to', '<=', date(date_from.year, 12, 31)),
|
|
])
|
|
if existing_double_pay:
|
|
result = 0
|
|
return (result_qty, result_rate, result, result_name)
|