# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import re from collections import defaultdict from datetime import datetime from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools.float_utils import float_compare class Payslip(models.Model): _inherit = 'hr.payslip' l10n_hk_worked_days_leaves_count = fields.Integer( 'Worked Days Leaves Count', compute='_compute_worked_days_leaves_count') l10n_hk_713_gross = fields.Monetary( '713 Gross', compute='_compute_gross', store=True) l10n_hk_mpf_gross = fields.Monetary( 'MPF Gross', compute='_compute_gross', store=True) l10n_hk_autopay_gross = fields.Monetary( 'AutoPay Gross', compute='_compute_gross', store=True) l10n_hk_second_batch_autopay_gross = fields.Monetary( 'Second Batch AutoPay Gross', compute='_compute_gross', store=True) @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') def _compute_gross(self): line_values = (self._origin)._get_line_values(['713_GROSS', 'MPF_GROSS', 'MEA', 'SBA']) for payslip in self: payslip.l10n_hk_713_gross = line_values['713_GROSS'][payslip._origin.id]['total'] payslip.l10n_hk_mpf_gross = line_values['MPF_GROSS'][payslip._origin.id]['total'] payslip.l10n_hk_autopay_gross = line_values['MEA'][payslip._origin.id]['total'] payslip.l10n_hk_second_batch_autopay_gross = line_values['SBA'][payslip._origin.id]['total'] def _get_paid_amount(self): self.ensure_one() res = super()._get_paid_amount() if self.struct_id.country_id.code != 'HK': return res if float_compare(res, self._get_contract_wage(), precision_rounding=0.1) == 0: return self._get_contract_wage() return res @api.model def _get_last_year_payslips_domain(self, date_from, date_to, employee_ids=None): domain = [ ('state', 'in', ['paid', 'done']), ('date_from', '>=', date_from + relativedelta(months=-12, day=1)), ('date_to', '<', date_to + relativedelta(day=1)), ('struct_id', '=', self.env.ref('l10n_hk_hr_payroll.hr_payroll_structure_cap57_employee_salary').id), ] if employee_ids: domain = expression.AND([domain, [('employee_id', 'in', employee_ids)]]) return domain def _get_moving_daily_wage(self): self.ensure_one() moving_daily_wage = sum(self.input_line_ids.filtered(lambda line: line.code == 'MOVING_DAILY_WAGE').mapped('amount')) if moving_daily_wage: return moving_daily_wage payslips_per_employee = self._get_last_year_payslips_per_employee(self.date_from, self.date_to) payslips = payslips_per_employee[self.employee_id] domain = self._get_last_year_payslips_domain(self.date_from, self.date_to) last_year_payslips = payslips.filtered_domain(domain).sorted(lambda slip: slip.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: return gross / number_of_days return 0 def _get_number_of_non_full_pay_days(self): wds = self.worked_days_line_ids.filtered(lambda wd: wd.work_entry_type_id.l10n_hk_non_full_pay) return sum([wd.number_of_days for wd in wds]) def _get_number_of_worked_days(self, only_full_pay=False): 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_last_year_payslips_per_employee(self, date_from, date_to): domain = self._get_last_year_payslips_domain(date_from, date_to, self.employee_id.ids) payslips = self.env['hr.payslip'].search(domain) payslips_per_employee = defaultdict(lambda: self.env['hr.payslip']) for payslip in payslips: payslips_per_employee[payslip.employee_id] += payslip return payslips_per_employee 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): self.ensure_one() res = super()._get_worked_day_lines_values(domain) if self.struct_id.country_id.code != 'HK': return res current_month_domain = expression.AND( [domain, ['|', ('leave_id', '=', False), ('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()) remainig_work_entries_domain = expression.AND([domain, [('leave_id.date_from', '<', self.date_from)]]) work_entries_dict = self.env['hr.work.entry']._read_group( self.contract_id._get_work_hours_domain(date_from, date_to, domain=remainig_work_entries_domain, inside=True), ['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_contract=True): self.ensure_one() res = super()._get_worked_day_lines(domain, check_out_of_contract) if self.struct_id.country_id.code != 'HK': return res contract = self.contract_id if contract.resource_calendar_id: if not check_out_of_contract: return res out_days, out_hours = 0, 0 reference_calendar = self._get_out_of_contract_calendar() domain = expression.AND([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'] if out_days or out_hours: work_entry_type = self.env.ref('hr_payroll.hr_work_entry_type_out_of_contract') 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): total = 0 for wd_line in self.worked_days_line_ids: if not wd_line.work_entry_type_id.l10n_hk_non_full_pay: continue total += wd_line.amount return total 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}{int(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'{int(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(_("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(_("Only support generating the HSBC autopay report for one company.")) currencies = self.mapped('currency_id') if len(currencies) > 1: raise UserError(_("Only support generating the HSBC autopay report for one currency")) invalid_employees = self.mapped('employee_id').filtered(lambda e: not e.bank_account_id) if invalid_employees: raise UserError(_("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(_("Some employees (%s) haven't set the autopay type.", ','.join(invalid_employees.mapped('name')))) invalid_banks = self.employee_id.bank_account_id.mapped('bank_id').filtered(lambda b: not b.l10n_hk_bank_code) if invalid_banks: raise UserError(_("Some banks (%s) don't have a bank code", ','.join(invalid_banks.mapped('name')))) invalid_bank_accounts = self.mapped('employee_id').filtered( lambda e: e.l10n_hk_autopay_account_type in ['bban', 'hkid'] and not e.bank_account_id.acc_holder_name) if invalid_bank_accounts: raise UserError(_("Some bank accounts (%s) don't have a bank account name.", ','.join(invalid_bank_accounts.mapped('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(_("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: payments_data.append({ 'id': payslip.id, 'ref': payslip.employee_id.l10n_hk_autopay_ref or '', 'type': payslip.employee_id.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]', '', payslip.employee_id.identification_id or ''), 'bank_code': payslip.employee_id.get_l10n_hk_autopay_bank_code(), 'autopay_field': payslip.employee_id.get_l10n_hk_autopay_field(), 'bank_account_name': payslip.employee_id.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): res = super().write(vals) if 'input_line_ids' in vals: self.filtered(lambda p: p.struct_id.country_id.code == 'HK' and p.state in ['draft', 'verify']).action_refresh_from_work_entries() return res def action_payslip_done(self): 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', 'in', ['draft', 'verify']), ('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