[ADD] hr_payroll_hibou: for Odoo Enterprise 14.0

Abstract fixes and behaviors.

- Adds configurable option for Payslip rule calculation sum date field.
- Fixes inconsistency between .sum_category('CODE') and .categories['CODE']
- Adds semi-monthly Semi-monthly schedule_pay

All features are tested and the tests themselves should serve as test harnesses for other 'payroll' modules.
This commit is contained in:
Jared Kipe
2020-11-21 17:19:37 -08:00
parent 7e0a839dda
commit 7fe275a21d
11 changed files with 560 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -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. <hello@hibou.io>',
'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',
}

View File

@@ -0,0 +1,4 @@
from . import browsable_object
from . import hr_payslip
from . import hr_salary_rule
from . import res_config_settings

View File

@@ -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

View File

@@ -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

View File

@@ -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'),
])

View File

@@ -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')

View File

@@ -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

153
hr_payroll_hibou/tests/common.py Executable file
View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_hibou" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.hr.payroll.hibou</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="50"/>
<field name="inherit_id" ref="hr_payroll.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@data-key='hr_payroll']" position="inside">
<field name="module_l10n_us_hr_payroll" invisible="1"/>
<!-- TODO payment, attendance, timesheet, ovetrtime... -->
<h2>Hibou Payroll</h2>
<div class="row mt16 o_settings_container" id="hr_payroll_hibou">
<div class="col-lg-6 col-12 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="payslip_sum_type" string="Payslip Sum Behavior"/>
<div class="text-muted">
Customize the behavior of what payslips are eligible when summing over date ranges in rules.
Generally, "Date To" or "Accounting Date" would be preferred in the United States and anywhere
else where the ending date on the payslip is used to calculate wage bases.
</div>
<field name="payslip_sum_type"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>