diff --git a/hr_payroll_hibou/__init__.py b/hr_payroll_hibou/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/hr_payroll_hibou/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_payroll_hibou/__manifest__.py b/hr_payroll_hibou/__manifest__.py new file mode 100644 index 00000000..94ba5190 --- /dev/null +++ b/hr_payroll_hibou/__manifest__.py @@ -0,0 +1,27 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Hibou Payroll', + 'author': 'Hibou Corp. ', + 'version': '14.0.1.0.0', + 'category': 'Payroll Localization', + 'depends': [ + 'hr_payroll', + 'hr_contract_reports', + 'hibou_professional', + ], + 'description': """ +Hibou Payroll +============= + +Base module for fixing specific qwerks or assumptions in the way Payroll Odoo Enterprise Edition behaves. + + """, + 'data': [ + 'views/res_config_settings_views.xml', + ], + 'demo': [ + ], + 'auto_install': True, + 'license': 'OPL-1', +} diff --git a/hr_payroll_hibou/models/__init__.py b/hr_payroll_hibou/models/__init__.py new file mode 100644 index 00000000..4538599a --- /dev/null +++ b/hr_payroll_hibou/models/__init__.py @@ -0,0 +1,4 @@ +from . import browsable_object +from . import hr_payslip +from . import hr_salary_rule +from . import res_config_settings diff --git a/hr_payroll_hibou/models/browsable_object.py b/hr_payroll_hibou/models/browsable_object.py new file mode 100644 index 00000000..60543eb8 --- /dev/null +++ b/hr_payroll_hibou/models/browsable_object.py @@ -0,0 +1,151 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields +from odoo.addons.hr_payroll.models import browsable_object + + +class BrowsableObject(object): + def __init__(self, employee_id, dict, env): + self.employee_id = employee_id + self.dict = dict + self.env = env + # Customization to allow changing the behavior of the discrete browsable objects. + # you can think of this as 'compiling' the query based on the configuration. + sum_field = env['ir.config_parameter'].sudo().get_param('hr_payroll.payslip.sum_behavior', 'date_from') + if sum_field == 'date' and 'date' not in env['hr.payslip']: + # missing attribute, closest by definition + sum_field = 'date_to' + if not sum_field: + sum_field = 'date_from' + self._compile_browsable_query(sum_field) + + def __getattr__(self, attr): + return attr in self.dict and self.dict.__getitem__(attr) or 0.0 + + def __getitem__(self, key): + return self.dict[key] or 0.0 + + def _compile_browsable_query(self, sum_field): + pass + + +class InputLine(BrowsableObject): + """a class that will be used into the python code, mainly for usability purposes""" + def _compile_browsable_query(self, sum_field): + self.__browsable_query = """ + SELECT sum(amount) as sum + FROM hr_payslip as hp, hr_payslip_input as pi + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.{sum_field} >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""".format(sum_field=sum_field) + + def sum(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + self.env.cr.execute(self.__browsable_query, (self.employee_id, from_date, to_date, code)) + return self.env.cr.fetchone()[0] or 0.0 + + +class WorkedDays(BrowsableObject): + """a class that will be used into the python code, mainly for usability purposes""" + def _compile_browsable_query(self, sum_field): + self.__browsable_query = """ + SELECT sum(number_of_days) as number_of_days, sum(number_of_hours) as number_of_hours + FROM hr_payslip as hp, hr_payslip_worked_days as pi + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.{sum_field} >= %s AND hp.date_to <= %s AND hp.id = pi.payslip_id AND pi.code = %s""".format(sum_field=sum_field) + + def _sum(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + self.env.cr.execute(self.__browsable_query, (self.employee_id, from_date, to_date, code)) + return self.env.cr.fetchone() + + def sum(self, code, from_date, to_date=None): + res = self._sum(code, from_date, to_date) + return res and res[0] or 0.0 + + def sum_hours(self, code, from_date, to_date=None): + res = self._sum(code, from_date, to_date) + return res and res[1] or 0.0 + + +class Payslips(BrowsableObject): + """a class that will be used into the python code, mainly for usability purposes""" + def _compile_browsable_query(self, sum_field): + # Note that the core odoo has this as `hp.credit_note = False` but what if it is NULL? + # reverse of the desired behavior. + self.__browsable_query_rule = """ + SELECT sum(case when hp.credit_note is not True then (pl.total) else (-pl.total) end) + FROM hr_payslip as hp, hr_payslip_line as pl + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.{sum_field} >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id AND pl.code = %s""".format(sum_field=sum_field) + # Original (non-recursive) + # self.__browsable_query_category = """ + # SELECT sum(case when hp.credit_note is not True then (pl.total) else (-pl.total) end) + # FROM hr_payslip as hp, hr_payslip_line as pl, hr_salary_rule_category as rc + # WHERE hp.employee_id = %s AND hp.state = 'done' + # AND hp.{sum_field} >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id + # AND rc.id = pl.category_id AND rc.code = %s""".format(sum_field=sum_field) + + # Hibou Recursive version + self.__browsable_query_category = """ + WITH RECURSIVE + category_by_code as ( + SELECT id + FROM hr_salary_rule_category + WHERE code = %s + ), + category_ids as ( + SELECT COALESCE((SELECT id FROM category_by_code), -1) AS id + UNION ALL + SELECT rc.id + FROM hr_salary_rule_category AS rc + JOIN category_ids AS rcs ON rcs.id = rc.parent_id + ) + + SELECT sum(case when hp.credit_note is not True then (pl.total) else (-pl.total) end) + FROM hr_payslip as hp, hr_payslip_line as pl + WHERE hp.employee_id = %s AND hp.state = 'done' + AND hp.{sum_field} >= %s AND hp.date_to <= %s AND hp.id = pl.slip_id + AND pl.category_id in (SELECT id from category_ids)""".format(sum_field=sum_field) + + def sum(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + self.env.cr.execute(self.__browsable_query_rule, (self.employee_id, from_date, to_date, code)) + res = self.env.cr.fetchone() + return res and res[0] or 0.0 + + def rule_parameter(self, code): + return self.env['hr.rule.parameter']._get_parameter_from_code(code, self.dict.date_to) + + def sum_category(self, code, from_date, to_date=None): + if to_date is None: + to_date = fields.Date.today() + + self.env['hr.payslip'].flush(['credit_note', 'employee_id', 'state', 'date_from', 'date_to']) + self.env['hr.payslip.line'].flush(['total', 'slip_id', 'category_id']) + self.env['hr.salary.rule.category'].flush(['code']) + + # standard version + # self.env.cr.execute(self.__browsable_query_category, (self.employee_id, from_date, to_date, code)) + # recursive category version + self.env.cr.execute(self.__browsable_query_category, (code, self.employee_id, from_date, to_date)) + res = self.env.cr.fetchone() + return res and res[0] or 0.0 + + @property + def paid_amount(self): + return self.dict._get_paid_amount() + + +# Patch over Core +browsable_object.BrowsableObject.__init__ = BrowsableObject.__init__ +browsable_object.BrowsableObject._compile_browsable_query = BrowsableObject._compile_browsable_query +browsable_object.InputLine._compile_browsable_query = InputLine._compile_browsable_query +browsable_object.InputLine.sum = InputLine.sum +browsable_object.WorkedDays._compile_browsable_query = WorkedDays._compile_browsable_query +browsable_object.WorkedDays.sum = WorkedDays.sum +browsable_object.Payslips._compile_browsable_query = Payslips._compile_browsable_query +browsable_object.Payslips.sum = Payslips.sum +browsable_object.Payslips.sum_category = Payslips.sum_category diff --git a/hr_payroll_hibou/models/hr_payslip.py b/hr_payroll_hibou/models/hr_payslip.py new file mode 100644 index 00000000..1a3ffffc --- /dev/null +++ b/hr_payroll_hibou/models/hr_payslip.py @@ -0,0 +1,14 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + def get_year(self): + """ + # Helper method to get the year (normalized between Odoo Versions) + :return: int year of payslip + """ + return self.date_to.year diff --git a/hr_payroll_hibou/models/hr_salary_rule.py b/hr_payroll_hibou/models/hr_salary_rule.py new file mode 100644 index 00000000..9ad2234d --- /dev/null +++ b/hr_payroll_hibou/models/hr_salary_rule.py @@ -0,0 +1,19 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class HrPayrollStructure(models.Model): + _inherit = 'hr.payroll.structure' + + schedule_pay = fields.Selection(selection_add=[ + ('semi-monthly', 'Semi-monthly'), + ], ondelete={'semi-monthly': 'set null'}) + + +class HrPayrollStructureType(models.Model): + _inherit = 'hr.payroll.structure.type' + + default_schedule_pay = fields.Selection(selection_add=[ + ('semi-monthly', 'Semi-monthly'), + ]) diff --git a/hr_payroll_hibou/models/res_config_settings.py b/hr_payroll_hibou/models/res_config_settings.py new file mode 100644 index 00000000..98faeeac --- /dev/null +++ b/hr_payroll_hibou/models/res_config_settings.py @@ -0,0 +1,27 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + # TODO We need MORE here... + module_l10n_us_hr_payroll = fields.Boolean(string='USA Payroll') + + payslip_sum_type = fields.Selection([ + ('date_from', 'Date From'), + ('date_to', 'Date To'), + ('date', 'Accounting Date'), + ], 'Payslip Sum Behavior', help="Behavior for what payslips are considered " + "during rule execution. Stock Odoo behavior " + "would not consider a payslip starting on 2019-12-30 " + "ending on 2020-01-07 when summing a 2020 payslip category.\n\n" + "Accounting Date requires Payroll Accounting and will " + "fall back to date_to as the 'closest behavior'.", + config_parameter='hr_payroll.payslip.sum_behavior') + + def set_values(self): + super(ResConfigSettings, self).set_values() + self.env['ir.config_parameter'].set_param('hr_payroll.payslip.sum_behavior', + self.payslip_sum_type or 'date_from') diff --git a/hr_payroll_hibou/tests/__init__.py b/hr_payroll_hibou/tests/__init__.py new file mode 100644 index 00000000..cf74eea4 --- /dev/null +++ b/hr_payroll_hibou/tests/__init__.py @@ -0,0 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import common + +from . import test_special diff --git a/hr_payroll_hibou/tests/common.py b/hr_payroll_hibou/tests/common.py new file mode 100755 index 00000000..d1423abe --- /dev/null +++ b/hr_payroll_hibou/tests/common.py @@ -0,0 +1,153 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from logging import getLogger +from sys import float_info as sys_float_info +from collections import defaultdict + +from odoo.tests import common +from odoo.tools.float_utils import float_round as odoo_float_round + + +def process_payslip(payslip): + try: + payslip.action_payslip_done() + except AttributeError: + # v9 + payslip.process_sheet() + + +class TestPayslip(common.TransactionCase): + debug = False + _logger = getLogger(__name__) + + def setUp(self): + super(TestPayslip, self).setUp() + self.contract_model = self.env['hr.contract'] + self.env.user.tz = 'PST8PDT' + self.env.ref('resource.resource_calendar_std').tz = 'PST8PDT' + self.env['ir.config_parameter'].set_param('hr_payroll.payslip.sum_behavior', 'date_to') + self.structure_type = self.env['hr.payroll.structure.type'].create({ + 'name': 'Test Structure Type', + }) + self.structure = self.env['hr.payroll.structure'].create({ + 'name': 'Test Structure', + 'type_id': self.structure_type.id, + }) + self._log('structue_type %s and structure %s' % (self.structure_type, self.structure)) + self.structure_type.default_struct_id = self.structure + self.resource_calendar = self.ref('resource.resource_calendar_std') + + float_info = sys_float_info + + def float_round(self, value, digits): + return odoo_float_round(value, digits) + + _payroll_digits = -1 + + @property + def payroll_digits(self): + if self._payroll_digits == -1: + self._payroll_digits = self.env['decimal.precision'].precision_get('Payroll') + return self._payroll_digits + + def _log(self, message): + if self.debug: + self._logger.warning(message) + + def _createEmployee(self): + return self.env['hr.employee'].create({ + 'birthday': '1985-03-14', + 'country_id': self.ref('base.us'), + 'department_id': self.ref('hr.dep_rd'), + 'gender': 'male', + 'name': 'Jared' + }) + + def _get_contract_defaults(self, contract_values): + if not contract_values.get('state'): + contract_values['state'] = 'open' # Running + if not contract_values.get('structure_type_id'): + contract_values['structure_type_id'] = self.structure_type.id + if not contract_values.get('date_start'): + contract_values['date_start'] = '2016-01-01' + if not contract_values.get('date_end'): + contract_values['date_end'] = '2030-12-31' + if not contract_values.get('resource_calendar_id'): + contract_values['resource_calendar_id'] = self.resource_calendar + + # Compatibility with earlier Odoo versions + if not contract_values.get('journal_id') and hasattr(self.contract_model, 'journal_id'): + try: + contract_values['journal_id'] = self.env['account.journal'].search([('type', '=', 'general')], limit=1).id + except KeyError: + # Accounting not installed + pass + + def _createContract(self, employee, **kwargs): + if not 'schedule_pay' in kwargs: + kwargs['schedule_pay'] = 'monthly' + schedule_pay = kwargs['schedule_pay'] + contract_values = { + 'name': 'Test Contract', + 'employee_id': employee.id, + } + + for key, val in kwargs.items(): + # Assume any Odoo object is in a Many2one + if hasattr(val, 'id'): + val = val.id + found = False + if hasattr(self.contract_model, key): + contract_values[key] = val + found = True + if not found: + self._logger.warn('cannot locate attribute names "%s" on hr.contract().' % (key, )) + + self._get_contract_defaults(contract_values) + contract = self.contract_model.create(contract_values) + + # Compatibility with Odoo 14 + contract.structure_type_id.default_struct_id.schedule_pay = schedule_pay + return contract + + def _createPayslip(self, employee, date_from, date_to, skip_compute=False): + slip = self.env['hr.payslip'].create({ + 'name': 'Test %s From: %s To: %s' % (employee.name, date_from, date_to), + 'employee_id': employee.id, + 'date_from': date_from, + 'date_to': date_to + }) + # Included in hr.payslip.action_refresh_from_work_entries() as ov 14.0 EE + # slip._onchange_employee() + # as is the 'compute' that is almost always called immediaately after + if not skip_compute: + slip.action_refresh_from_work_entries() + return slip + + def _getCategories(self, payslip): + categories = defaultdict(float) + for line in payslip.line_ids: + self._log(' line code: ' + str(line.code) + + ' category code: ' + line.category_id.code + + ' total: ' + str(line.total) + + ' rate: ' + str(line.rate) + + ' amount: ' + str(line.amount)) + category_id = line.category_id + category_code = line.category_id.code + while category_code: + categories[category_code] += line.total + category_id = category_id.parent_id + category_code = category_id.code + return categories + + def _getRules(self, payslip): + rules = defaultdict(float) + for line in payslip.line_ids: + rules[line.code] += line.total + return rules + + def assertPayrollEqual(self, first, second): + self.assertAlmostEqual(first, second, self.payroll_digits) + + def assertPayrollAlmostEqual(self, first, second): + self.assertAlmostEqual(first, second, self.payroll_digits-1) diff --git a/hr_payroll_hibou/tests/test_special.py b/hr_payroll_hibou/tests/test_special.py new file mode 100644 index 00000000..077f2de3 --- /dev/null +++ b/hr_payroll_hibou/tests/test_special.py @@ -0,0 +1,127 @@ +from .common import TestPayslip, process_payslip + + +class TestSpecial(TestPayslip): + + def test_get_year(self): + salary = 80000.0 + employee = self._createEmployee() + # so the schedule_pay is now on the Structure... + contract = self._createContract(employee, wage=salary) + payslip = self._createPayslip(employee, '2020-01-01', '2020-01-14') + self.assertEqual(payslip.get_year(), 2020) + + def test_semi_monthly(self): + salary = 80000.0 + employee = self._createEmployee() + # so the schedule_pay is now on the Structure... + contract = self._createContract(employee, wage=salary, schedule_pay='semi-monthly') + payslip = self._createPayslip(employee, '2019-01-01', '2019-01-14') + + def test_payslip_sum_behavior(self): + self.env['ir.config_parameter'].set_param('hr_payroll.payslip.sum_behavior', 'date') + rule_category_comp = self.env.ref('hr_payroll.COMP') + test_rule_category = self.env['hr.salary.rule.category'].create({ + 'name': 'Test Sum Behavior', + 'code': 'test_sum_behavior', + 'parent_id': rule_category_comp.id, + }) + test_rule = self.env['hr.salary.rule'].create({ + 'sequence': 450, + 'struct_id': self.structure.id, + 'category_id': test_rule_category.id, + 'name': 'Test Sum Behavior', + 'code': 'test_sum_behavior', + 'condition_select': 'python', + 'condition_python': 'result = 1', + 'amount_select': 'code', + 'amount_python_compute': ''' +ytd_category = payslip.sum_category('test_sum_behavior', '2020-01-01', '2021-01-01') +ytd_rule = payslip.sum('test_sum_behavior', '2020-01-01', '2021-01-01') +result = 0.0 +if ytd_category != ytd_rule: + # error + result = -1.0 +elif ytd_rule == 0.0: + # first payslip in period + result = 1.0 +''' + }) + salary = 80000.0 + employee = self._createEmployee() + contract = self._createContract(employee, wage=salary, schedule_pay='bi-weekly') + payslip = self._createPayslip(employee, '2019-12-30', '2020-01-12') + cats = self._getCategories(payslip) + self.assertEqual(cats['BASIC'], salary) + self.assertEqual(cats['test_sum_behavior'], 1.0) + process_payslip(payslip) + + # Basic date_from behavior. + self.env['ir.config_parameter'].set_param('hr_payroll.payslip.sum_behavior', 'date_from') + # The the date_from on the last payslip will not be found + payslip = self._createPayslip(employee, '2020-01-13', '2020-01-27') + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertEqual(cats['test_sum_behavior'], 1.0) + + # date_to behavior. + self.env['ir.config_parameter'].set_param('hr_payroll.payslip.sum_behavior', 'date_to') + # The date_to on the last payslip is found + payslip = self._createPayslip(employee, '2020-01-13', '2020-01-27') + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertEqual(cats['test_sum_behavior'], 0.0) + + def test_recursive_salary_rule_category(self): + self.debug = True + # In this scenario, you are in rule code that will check for the category + # and a subcategory will also + alw_category = self.env.ref('hr_payroll.ALW') + ded_category = self.env.ref('hr_payroll.DED') + test_category = self.env['hr.salary.rule.category'].create({ + 'name': 'Special ALW', + 'code': 'ALW_SPECIAL_RECURSIVE', + 'parent_id': alw_category.id, + }) + test_special_alw = self.env['hr.salary.rule'].create({ + 'name': 'Flat amount 200', + 'code': 'ALW_SPECIAL_RECURSIVE', + 'category_id': test_category.id, + 'condition_select': 'none', + 'amount_select': 'fix', + 'amount_fix': 200.0, + 'struct_id': self.structure.id, + }) + test_recursion = self.env['hr.salary.rule'].create({ + 'name': 'Actual Test Behavior', + 'code': 'RECURSION_TEST', + 'category_id': ded_category.id, + 'condition_select': 'none', + 'amount_select': 'code', + 'amount_python_compute': """ +# this rule will always be the total of the ALW category and YTD ALW category +result = categories.ALW +# Note, this tests the hr.payslip.get_year() to return an integer rep of year +year = payslip.dict.get_year() +result += payslip.sum_category('ALW', str(year) + '-01-01', str(year+1) + '-01-01') + """, + 'sequence': 101, + 'struct_id': self.structure.id, + }) + + salary = 80000.0 + employee = self._createEmployee() + contract = self._createContract(employee, wage=salary, schedule_pay='bi-weekly') + payslip = self._createPayslip(employee, '2020-01-01', '2020-01-14') + payslip.compute_sheet() + cats = self._getCategories(payslip) + rules = self._getRules(payslip) + self.assertEqual(rules['RECURSION_TEST'], 200.0) + process_payslip(payslip) + + payslip = self._createPayslip(employee, '2020-01-15', '2020-01-27') + payslip.compute_sheet() + cats = self._getCategories(payslip) + rules = self._getRules(payslip) + # two hundred is in the YTD ALW + self.assertEqual(rules['RECURSION_TEST'], 200.0 + 200.0) diff --git a/hr_payroll_hibou/views/res_config_settings_views.xml b/hr_payroll_hibou/views/res_config_settings_views.xml new file mode 100644 index 00000000..a4dc1854 --- /dev/null +++ b/hr_payroll_hibou/views/res_config_settings_views.xml @@ -0,0 +1,32 @@ + + + + + res.config.settings.view.form.inherit.hr.payroll.hibou + res.config.settings + + + + + + +

Hibou Payroll

+
+
+
+
+
+
+
+ + + + +