Files
enterprise-addons/l10n_hk_hr_payroll/models/hr_payslip.py
T

554 lines
27 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import re
from collections import defaultdict
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.fields import Domain
from odoo.tools.float_utils import float_compare
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
l10n_hk_worked_days_leaves_count = fields.Integer(
string='Worked Days Leaves Count',
compute='_compute_worked_days_leaves_count',
)
l10n_hk_713_gross = fields.Monetary(
string='713 Gross',
compute='_compute_gross',
store=True,
)
l10n_hk_mpf_gross = fields.Monetary(
string='MPF Gross',
compute='_compute_gross',
store=True,
)
l10n_hk_autopay_gross = fields.Monetary(
string='AutoPay Gross',
compute='_compute_gross',
store=True,
)
l10n_hk_second_batch_autopay_gross = fields.Monetary(
string='Second Batch AutoPay Gross',
compute='_compute_gross',
store=True,
)
l10n_hk_average_daily_wage = fields.Monetary(
string='Average Daily Wage',
help='Calculated as per the Employment (Amendment) Ordinance 2007: (Total of fully paid wages earned in the 12-month period) / (Total number of fully paid days in that period).',
compute='_compute_average_daily_wage',
)
l10n_hk_includes_eoy_pay = fields.Boolean(
string='End-Of-Year Pay',
compute='_compute_includes_eoy_pay',
store=True,
readonly=False,
)
l10n_hk_use_mpf_offsetting = fields.Boolean(
string='Apply MPF Offsetting',
compute='_compute_l10n_hk_use_mpf_offsetting',
store=True,
readonly=False,
)
def _get_data_files_to_update(self):
# Note: file order should be maintained
return super()._get_data_files_to_update() + [(
'l10n_hk_hr_payroll', [
'data/hr_salary_rule_category_data.xml',
'data/cap57/employee_long_service_payment_data.xml',
'data/cap57/employee_payment_in_lieu_of_notice_data.xml',
'data/cap57/employee_salary_data.xml',
'data/cap57/employee_severance_payment_data.xml',
'data/hr_rule_parameters_data.xml',
])]
@api.depends('worked_days_line_ids')
def _compute_worked_days_leaves_count(self):
for payslip in self:
payslip.l10n_hk_worked_days_leaves_count = len(payslip.worked_days_line_ids.filtered(lambda wd: wd.l10n_hk_leave_id))
@api.depends('line_ids.total', 'struct_id')
def _compute_gross(self):
"""
Compute gross amounts at the time of this payslip.
They will be made available in the Payroll Analysis report.
"""
related_struct = self.env.ref('l10n_hk_hr_payroll.hr_payroll_structure_cap57_employee_salary', raise_if_not_found=False)
if not related_struct:
return
hk_slip = self.filtered(lambda s: s.struct_id == related_struct)
line_values = hk_slip._get_line_values(['713_GROSS', 'MPF_GROSS', 'MEA', 'SBA'])
for payslip in hk_slip:
payslip.l10n_hk_713_gross = line_values['713_GROSS'][payslip.id]['total']
payslip.l10n_hk_mpf_gross = line_values['MPF_GROSS'][payslip.id]['total']
payslip.l10n_hk_autopay_gross = line_values['MEA'][payslip.id]['total']
payslip.l10n_hk_second_batch_autopay_gross = line_values['SBA'][payslip.id]['total']
@api.depends('input_line_ids')
def _compute_average_daily_wage(self):
"""
Calculate and return the Average Daily Wage (ADW), which is used to calculate payments for various statutory entitlements, including:
- Holiday Pay
- Annual Leave Pay
- Sickness Allowance
- Maternity and Paternity Leave Pay
- Payment in lieu of notice
The calculation is governed by the Employment (Amendment) Ordinance 2007:
ADW = (Total wages earned in the 12-month period) / (Total number of days in that period)
In order to be fair to the employee, the total wage calculation must exclude days for which the employee was not
paid their full pay (sick leave,...) as well as the wages of these days.
The period in which to look for the ADW is based on the last 365 days, and not the last 12 months.
Example:
Natalie Chan takes an annual leave from August 4, 2025, to August 6, 2025.
The wages she received in the last 12 months are of HK350,000.
During this period,she took 5 days of unpaid leave and 2 days of sickness leave (for which she was paid a total of HK1,500).
The calculation should then be:
- Disregard the days of leave not fully paid from the total calendar days.
- Disregard the payments made for those specific leave days from the total wages.
So the ADW is: (HK$350,000-HK$1,500) / (365 - 5 - 2) = HK$973.46
:return: The ADW for the period.
"""
for slip in self:
if slip.country_code != 'HK' or not (slip.date_from and slip.date_to):
slip.l10n_hk_average_daily_wage = 0
continue
adw = 0
average_daily_wage = sum(slip.input_line_ids.filtered(lambda line: line.code == 'AVERAGE_DAILY_WAGE').mapped('amount'))
if average_daily_wage:
slip.l10n_hk_average_daily_wage = average_daily_wage
continue
last_year_payslips = slip._get_previous_year_payslips(order='date_from')
if last_year_payslips:
gross = last_year_payslips._get_line_values(['713_GROSS'], compute_sum=True)['713_GROSS']['sum']['total']
gross -= last_year_payslips._get_total_non_full_pay()
number_of_days = last_year_payslips._get_number_of_worked_days(only_full_pay=True)
if number_of_days > 0:
adw = gross / number_of_days
slip.l10n_hk_average_daily_wage = adw
@api.depends('date_from', 'date_to', 'company_id')
def _compute_includes_eoy_pay(self):
for slip in self.filtered(lambda s: s.country_code == 'HK'):
if not (slip.company_id.l10n_hk_eoy_pay_month and slip.date_from and slip.date_to):
slip.l10n_hk_includes_eoy_pay = False
continue
last_year_payslips = slip._get_previous_year_payslips(order='date_from desc')
slip.l10n_hk_includes_eoy_pay = str(slip.date_to.month) == slip.company_id.l10n_hk_eoy_pay_month and last_year_payslips
@api.depends('company_id')
def _compute_l10n_hk_use_mpf_offsetting(self):
for slip in self.filtered(lambda s: s.country_code == 'HK'):
slip.l10n_hk_use_mpf_offsetting = slip.company_id.l10n_hk_use_mpf_offsetting
def _get_base_local_dict(self):
"""
In Hong Kong, there are a few rules that are dependent on a monthly amount.
While it works well in most cases; there is a case where a change in contract is done in the middle of a period.
In this case, the payrun would contain two payslips for the same employee! To avoid counting twice the amounts,
we need to know at the time of calculating the rule what has already been registered in these rules.
"""
res = super()._get_base_local_dict()
if self.struct_id.country_id.code != 'HK':
return res
# Initialize the dictionary to guarantee the keys exist
l10n_hk_payrun_totals = {
'MPF_GROSS': 0.0, 'BASIC': 0.0, 'EEMC': 0.0,
'ERMC': 0.0, 'EEVC': 0.0, 'ERVC': 0.0,
'ERVC2': 0.0, 'HRA': 0.0, 'ALW.INT': 0.0
}
if self.payslip_run_id:
# /!\ during the computation of multiple payslips for a same period (split for new contract) the payslips are
# draft while we compute the lines. So we need to make sure we do not only pick paid/confirmed payslips like usual.
previous_slips = self.payslip_run_id.slip_ids.filtered(
lambda p: p.employee_id == self.employee_id and p.version_id.date_start < self.version_id.date_start
)
if previous_slips:
values = previous_slips._get_line_values(list(l10n_hk_payrun_totals.keys()), compute_sum=True)
for (code, value) in values.items():
l10n_hk_payrun_totals[code] = value['sum']['total']
worked_days_prorata_rate = 1
total_days = sum(wd.number_of_days for wd in self.worked_days_line_ids)
actual_work_days = self._get_number_of_worked_days()
if total_days:
worked_days_prorata_rate = actual_work_days / total_days
return {
**res,
'l10n_hk_payrun_totals': l10n_hk_payrun_totals,
'worked_days_prorata_rate': worked_days_prorata_rate,
}
def _get_paid_amount(self):
"""
When the paid amount is very slightly off from the wage, we assume that it is due to a rounding
error and return the wage amount instead of the computed value.
"""
self.ensure_one()
res = super()._get_paid_amount()
if self.struct_id.country_id.code == 'HK':
if float_compare(res, self._get_contract_wage(), precision_rounding=0.1) == 0:
return self._get_contract_wage()
return res
def _get_previous_year_payslips(self, order=None):
"""
Returns all payslips from the previous year, for the same struc and employee as the one being used in the payslip in
self.
:param order: Optional order that can be used instead of the default one when searching for the payslips.
:return: The recordset of matching payslips from the previous year.
"""
self.ensure_one()
return self.env['hr.payslip'].search([
("state", "in", ["paid", "validated"]),
("date_from", ">=", self.date_from + relativedelta(months=-12, day=1)),
("date_to", "<", self.date_to + relativedelta(day=1)),
("struct_id", "=", self.env.ref('l10n_hk_hr_payroll.hr_payroll_structure_cap57_employee_salary').id),
("employee_id", "=", self.employee_id.id),
], order=order)
def _get_number_of_non_full_pay_days(self):
"""
Calculates the amount of days for which the employee was not paid their full wage.
This is important information when calculating the Average Daily Wage (ADW).
:return: Amount of non-full pay days.
"""
wds = self.worked_days_line_ids.filtered(lambda wd: wd.work_entry_type_id.amount_rate < 1)
return sum([wd.number_of_days for wd in wds])
def _get_number_of_worked_days(self, only_full_pay=False):
"""
Calculates the amount of days during which the employee worked.
:param only_full_pay: Optionally, filter out days were the pay was not their full wage.
:return: Amount of worked days.
"""
wds = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE90', 'OUT'])
number_of_days = sum([wd.number_of_days for wd in wds])
if only_full_pay:
return number_of_days - self._get_number_of_non_full_pay_days()
return number_of_days
def _get_credit_time_lines(self):
if self.struct_id.country_id.code != 'HK':
return super()._get_credit_time_lines()
return []
def _get_worked_day_lines_values(self, domain=None):
"""
Calculate the values that should be used to calculate the worked days lines of the payslip.
Adds support for leave starting before the payslip period.
:param domain: An optional domain used to filter work entries.
:return: The worked days lines values.
"""
self.ensure_one()
res = super()._get_worked_day_lines_values(domain)
if self.struct_id.country_id.code != 'HK':
return res
domain = Domain(domain or Domain.TRUE)
current_month_domain = domain & (
Domain('leave_id', '=', False)
| Domain('leave_id.date_from', '>=', self.date_from)
)
res = super()._get_worked_day_lines_values(current_month_domain)
hours_per_day = self._get_worked_day_lines_hours_per_day()
date_from = datetime.combine(self.date_from, datetime.min.time())
date_to = datetime.combine(self.date_to, datetime.max.time())
remaining_work_entries_domain = domain & Domain('leave_id.date_from', '<', self.date_from)
work_entries_dict = self.env['hr.work.entry']._read_group(
self.version_id._get_work_hours_domain(date_from, date_to, domain=remaining_work_entries_domain),
['leave_id', 'work_entry_type_id'],
['duration:sum'],
)
work_entries = defaultdict(tuple)
work_entries.update({
(work_entry_type.id, leave.id): hours
for leave, work_entry_type, hours in work_entries_dict
})
for work_entry, hours in work_entries.items():
work_entry_id, leave_id = work_entry
work_entry_type = self.env['hr.work.entry.type'].browse(work_entry_id)
days = round(hours / hours_per_day, 5) if hours_per_day else 0
day_rounded = self._round_days(work_entry_type, days)
res.append({
'sequence': work_entry_type.sequence,
'work_entry_type_id': work_entry_id,
'number_of_days': day_rounded,
'number_of_hours': hours,
'l10n_hk_leave_id': leave_id,
})
return res
def _get_worked_day_lines(self, domain=None, check_out_of_version=True):
"""
Calculate worked days values to apply on the payslip.
If the employee is out of contract during a part of the period, a out of contract line will be added to fill the
gap.
:returns: a list of dict containing the worked days values that should be applied for the given payslip
"""
self.ensure_one()
domain = Domain(domain or Domain.TRUE)
res = super()._get_worked_day_lines(domain, check_out_of_version)
if self.struct_id.country_id.code != 'HK':
return res
contract = self.version_id
if contract.resource_calendar_id:
if not check_out_of_version:
return res
out_days, out_hours = 0, 0
reference_calendar = self._get_out_of_contract_calendar()
domain &= Domain('work_entry_type_id.is_leave', '=', True)
if self.date_from < contract.date_start:
start = fields.Datetime.to_datetime(self.date_from)
stop = fields.Datetime.to_datetime(contract.date_start) + relativedelta(days=-1, hour=23, minute=59)
out_time = reference_calendar.get_work_duration_data(start, stop, compute_leaves=False, domain=domain)
out_days += out_time['days']
out_hours += out_time['hours']
if contract.date_end and contract.date_end < self.date_to:
start = fields.Datetime.to_datetime(contract.date_end) + relativedelta(days=1)
stop = fields.Datetime.to_datetime(self.date_to) + relativedelta(hour=23, minute=59)
out_time = reference_calendar.get_work_duration_data(start, stop, compute_leaves=False, domain=domain)
out_days += out_time['days']
out_hours += out_time['hours']
work_entry_type = self.env.ref('hr_work_entry.hr_work_entry_type_out_of_contract', raise_if_not_found=False)
if work_entry_type and (out_days or out_hours):
existing = False
for worked_days in res:
if worked_days['work_entry_type_id'] == work_entry_type.id:
worked_days['number_of_days'] += out_days
worked_days['number_of_hours'] += out_hours
existing = True
break
if not existing:
res.append({
'sequence': work_entry_type.sequence,
'work_entry_type_id': work_entry_type.id,
'number_of_days': out_days,
'number_of_hours': out_hours,
})
return res
def _get_total_non_full_pay(self):
""" Calculate the total amount from all worked day lines concerning non-fully paid work entries. """
total = 0
for wd_line in self.worked_days_line_ids:
if wd_line.work_entry_type_id.amount_rate == 1:
continue
total += wd_line.amount
return total
def _get_713_gross_at_date(self, request_date):
"""
Helper returning the amount of the 713 gross at a specified date.
The way it is done is by finding the last monthly payslip before that date, and getting the value from it.
"""
employee_salary_struct = self.env.ref('l10n_hk_hr_payroll.hr_payroll_structure_cap57_employee_salary')
relevant_payslips = self.employee_id.slip_ids.filtered(
lambda s: s.struct_id == employee_salary_struct and s.date_to < request_date
)
if relevant_payslips:
latest_payslip = next(iter(relevant_payslips.sorted()))
if not latest_payslip:
return 0.0
return latest_payslip._get_line_values(['713_GROSS'])['713_GROSS'][latest_payslip.id]['total']
return 0.0
def _get_years_of_services_per_period(self):
"""
Calculate the years of services for the employee of the payslip, for both pre- and post-transition periods.
:return: a tuple (pre_transition_yos, post_transition_yos)
"""
self.ensure_one()
contracts = self.employee_id.version_ids.sorted("contract_date_start", reverse=True)
if not contracts:
return 0, 0
transition_date = date(2025, 5, 1)
contract_end_date = contracts[0].date_end or self.date_to
# Starts by calculating the pre-transition years of service.
pre_transition_end_date = transition_date - relativedelta(days=1) # April 30, 2025
pre_transition_years = self.employee_id._get_years_of_service(self.employee_id._get_first_version_date(), pre_transition_end_date)
# Continues by calculating the post-transition years of service.
post_transition_start_date = transition_date
post_transition_years = self.employee_id._get_years_of_service(max(self.employee_id._get_first_version_date(), post_transition_start_date), contract_end_date)
return pre_transition_years, post_transition_years
def _generate_h2h_autopay(self, header_data: dict) -> str:
ctime = datetime.now()
header = (
f'H{header_data["digital_pic_id"]:<11}HKMFPS02{"":<3}'
f'{header_data["customer_ref"]:<35}{ctime:%Y/%m/%d%H:%M:%S}'
f'{"":<1}{header_data["authorisation_type"]}{"":<2}PH{"":<79}\n'
)
return header
def _generate_hsbc_autopay(self, header_data: dict, payments_data: dict) -> str:
acc_number = re.sub(r"[^0-9]", "", header_data['autopay_partner_bank_id'].acc_number)
header = (
f'PHF{header_data["payment_set_code"]}{header_data["ref"]:<12}{header_data["payment_date"]:%Y%m%d}'
f'{acc_number + "SA" + header_data["currency"]:<35}'
f'{header_data["currency"]}{header_data["payslips_count"]:07}{round(header_data["amount_total"] * 100):017}'
f'{"":<1}{"":<311}\n'
)
datas = []
for payment in payments_data:
datas.append(
f'PD{payment["bank_code"]:<3}{payment["type"].upper()}{payment["autopay_field"]:<34}'
f'{round(payment["amount"] * 100):017}{payment["identifier"]:<35}{payment["ref"]:<35}'
f'{payment["bank_account_name"]:<140}{"":<130}'
)
data = '\n'.join(datas)
return header + data
def _create_apc_file(self, payment_date, payment_set_code: str, batch_type: str = 'first', ref: str = None, file_name: str = None, **kwargs):
invalid_payslips = self.filtered(lambda p: p.currency_id.name not in ['HKD', 'CNY'])
if invalid_payslips:
raise UserError(self.env._("Only accept HKD or CNY currency.\nInvalid currency for the following payslips:\n%s", '\n'.join(invalid_payslips.mapped('name'))))
companies = self.mapped('company_id')
if len(companies) > 1:
raise UserError(self.env._("Only support generating the HSBC autopay report for one company."))
currencies = self.mapped('currency_id')
if len(currencies) > 1:
raise UserError(self.env._("Only support generating the HSBC autopay report for one currency"))
invalid_employees = self.mapped('employee_id').filtered(lambda e: not e.bank_account_ids)
if invalid_employees:
raise UserError(self.env._("Some employees (%s) don't have a bank account.", ','.join(invalid_employees.mapped('name'))))
invalid_employees = self.mapped('employee_id').filtered(lambda e: not e.l10n_hk_autopay_account_type)
if invalid_employees:
raise UserError(self.env._("Some employees (%s) haven't set the autopay type.", ','.join(invalid_employees.mapped('name'))))
invalid_banks = self.employee_id.bank_account_ids.mapped('bank_id').filtered(lambda b: not b.l10n_hk_bank_code)
if invalid_banks:
raise UserError(self.env._("Some banks (%s) don't have a bank code", ','.join(invalid_banks.mapped('name'))))
# TODO: adapt for multiple bank accounts
invalid_bank_accounts = self.mapped('employee_id').filtered(
lambda e: e.l10n_hk_autopay_account_type in ['bban', 'hkid'] and not e.primary_bank_account_id.acc_holder_name)
if invalid_bank_accounts:
raise UserError(self.env._("Some bank accounts (%s) don't have a bank account name.", ','.join(invalid_bank_accounts.mapped('primary_bank_account_id.acc_number'))))
rule_code = {'first': 'MEA', 'second': 'SBA'}[batch_type]
payslips = self.filtered(lambda p: p.struct_id.code == 'CAP57MONTHLY' and p.line_ids.filtered(lambda line: line.code == rule_code))
if not payslips:
raise UserError(self.env._("No payslip to generate the HSBC autopay report."))
autopay_type = self.company_id.l10n_hk_autopay_type
if autopay_type == 'h2h':
h2h_header_data = {
'authorisation_type': kwargs.get('authorisation_type'),
'customer_ref': kwargs.get('customer_ref', ''),
'digital_pic_id': kwargs.get('digital_pic_id'),
'payment_date': payment_date,
}
header_data = {
'ref': ref,
'currency': payslips.currency_id.name,
'amount_total': sum(payslips.line_ids.filtered(lambda line: line.code == rule_code).mapped('amount')),
'payment_date': payment_date,
'payslips_count': len(payslips),
'payment_set_code': payment_set_code,
'autopay_partner_bank_id': payslips.company_id.l10n_hk_autopay_partner_bank_id,
}
payments_data = []
for payslip in payslips:
employee = payslip.employee_id
bank_code = ''
if employee.l10n_hk_autopay_account_type == 'bban':
# The bank code is only expected when the autopay type is set to bban
bank_code = employee.primary_bank_account_id.bank_id.l10n_hk_bank_code
# The identifier used depends on the employee autopay type
identifier = ''
match employee.l10n_hk_autopay_account_type:
case "bban":
identifier = re.sub(r"[^0-9]", "", employee.primary_bank_account_id.acc_number)
case "svid":
identifier = employee.l10n_hk_autopay_svid
case "emal":
identifier = employee.l10n_hk_autopay_email
case "mobn":
identifier = employee.l10n_hk_autopay_mobile
case "hkid":
identifier = employee.identification_id
payments_data.append({
'id': payslip.id,
'ref': employee.l10n_hk_autopay_ref or '',
'type': employee.l10n_hk_autopay_account_type,
'amount': sum(payslip.line_ids.filtered(lambda line: line.code == rule_code).mapped('amount')),
'identifier': re.sub(r'[^a-zA-Z0-9]', '', employee.identification_id or ''),
'bank_code': bank_code,
'autopay_field': identifier,
'bank_account_name': employee.primary_bank_account_id.acc_holder_name or '',
})
apc_doc = payslips._generate_hsbc_autopay(header_data, payments_data)
if autopay_type == 'h2h':
apc_doc = payslips._generate_h2h_autopay(h2h_header_data) + apc_doc
apc_binary = base64.encodebytes(apc_doc.encode('ascii'))
file_name = file_name and file_name.replace('.apc', '')
if batch_type == 'first':
payslips.mapped('payslip_run_id').write({
'l10n_hk_autopay_export_first_batch_date': payment_date,
'l10n_hk_autopay_export_first_batch': apc_binary,
'l10n_hk_autopay_export_first_batch_filename': (file_name or 'HSBC_Autopay_export_first_batch') + '.apc',
})
else:
payslips.mapped('payslip_run_id').write({
'l10n_hk_autopay_export_second_batch_date': payment_date,
'l10n_hk_autopay_export_second_batch': apc_binary,
'l10n_hk_autopay_export_second_batch_filename': (file_name or 'HSBC_Autopay_export_second_batch') + '.apc',
})
def write(self, vals):
""" Force the payslip to recompute itself when adding payslip inputs. """
res = super().write(vals)
if 'input_line_ids' in vals:
self.filtered(lambda p: p.struct_id.country_id.code == 'HK' and p.state == 'draft').action_refresh_from_work_entries()
return res
def action_payslip_done(self):
"""
Force recomputation of future payslips that are potentially already created to ensure the amounts reflect
the payslip that was just done.
"""
res = super().action_payslip_done()
if self.struct_id.country_id.code != 'HK':
return res
future_payslips = self.sudo().search([
('id', 'not in', self.ids),
('state', '=', 'draft'),
('employee_id', 'in', self.mapped('employee_id').ids),
('date_from', '>=', min(self.mapped('date_to'))),
])
if future_payslips:
future_payslips.action_refresh_from_work_entries()
return res