From d25f6d374c2b1c5e4f75ec24c85edd4e4add9aae Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jul 2020 13:59:07 -0700 Subject: [PATCH 01/12] [ADD] hr_payroll_overtime: for Odoo 13.0 --- hr_payroll_overtime/__init__.py | 1 + hr_payroll_overtime/__manifest__.py | 19 ++ hr_payroll_overtime/data/overtime_data.xml | 12 + hr_payroll_overtime/models/__init__.py | 3 + hr_payroll_overtime/models/hr_payslip.py | 106 +++++++ hr_payroll_overtime/models/hr_work_entry.py | 21 ++ .../models/resource_calendar.py | 15 + .../security/ir.model.access.csv | 3 + hr_payroll_overtime/tests/__init__.py | 1 + hr_payroll_overtime/tests/test_overtime.py | 271 ++++++++++++++++++ .../views/hr_work_entry_views.xml | 58 ++++ .../views/resource_calendar_views.xml | 15 + 12 files changed, 525 insertions(+) create mode 100644 hr_payroll_overtime/__init__.py create mode 100644 hr_payroll_overtime/__manifest__.py create mode 100644 hr_payroll_overtime/data/overtime_data.xml create mode 100644 hr_payroll_overtime/models/__init__.py create mode 100644 hr_payroll_overtime/models/hr_payslip.py create mode 100644 hr_payroll_overtime/models/hr_work_entry.py create mode 100644 hr_payroll_overtime/models/resource_calendar.py create mode 100644 hr_payroll_overtime/security/ir.model.access.csv create mode 100644 hr_payroll_overtime/tests/__init__.py create mode 100644 hr_payroll_overtime/tests/test_overtime.py create mode 100644 hr_payroll_overtime/views/hr_work_entry_views.xml create mode 100644 hr_payroll_overtime/views/resource_calendar_views.xml diff --git a/hr_payroll_overtime/__init__.py b/hr_payroll_overtime/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/hr_payroll_overtime/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py new file mode 100644 index 00000000..49e653f0 --- /dev/null +++ b/hr_payroll_overtime/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Payroll Overtime', + 'description': 'Provide mechanisms to calculate overtime.', + 'version': '13.0.1.0.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'data': [ + 'security/ir.model.access.csv', + 'data/overtime_data.xml', + 'views/hr_work_entry_views.xml', + 'views/resource_calendar_views.xml', + ], + 'depends': [ + 'hr_payroll', + 'hr_work_entry', + ], +} diff --git a/hr_payroll_overtime/data/overtime_data.xml b/hr_payroll_overtime/data/overtime_data.xml new file mode 100644 index 00000000..9316f1ab --- /dev/null +++ b/hr_payroll_overtime/data/overtime_data.xml @@ -0,0 +1,12 @@ + + + + + + Default Rules + 40.0 + 1.5 + + + + \ No newline at end of file diff --git a/hr_payroll_overtime/models/__init__.py b/hr_payroll_overtime/models/__init__.py new file mode 100644 index 00000000..3fcc81d4 --- /dev/null +++ b/hr_payroll_overtime/models/__init__.py @@ -0,0 +1,3 @@ +from . import hr_payslip +from . import hr_work_entry +from . import resource_calendar diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py new file mode 100644 index 00000000..13667cc9 --- /dev/null +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -0,0 +1,106 @@ +from collections import defaultdict +from odoo import models + + +class HRPayslip(models.Model): + _inherit = 'hr.payslip' + + def aggregate_overtime(self, work_data, day_week_start=None): + """ + + :param work_data: list(tuple(iso_date, list(tuple(hr.work.entry.type(), hours, original_record)) + :param day_week_start: day of the week to start (otherwise employee's resource calendar start day of week) + :return: dict(hr.work.entry.type(): list(days_worked, hours_worked, )) + """ + if not day_week_start: + if self.employee_id.resource_calendar_id.day_week_start: + day_week_start = self.employee_id.resource_calendar_id.day_week_start + else: + day_week_start = '1' + day_week_start = int(day_week_start) + if day_week_start < 1 or day_week_start > 7: + day_week_start = 1 + + def _adjust_week(isodate): + if isodate[2] < day_week_start: + return (isodate[0], isodate[1] + 1, isodate[2]) + return isodate + + result = defaultdict(lambda: [0.0, 0.0, 1.0]) + day_hours = defaultdict(float) + week_hours = defaultdict(float) + iso_days = set() + for iso_date, entries in work_data: + iso_date = _adjust_week(iso_date) + week = iso_date[1] + for work_type, hours, _ in entries: + if work_type.overtime_work_type_id and work_type.overtime_type_id: + ot_h_w = work_type.overtime_type_id.hours_per_week + ot_h_d = work_type.overtime_type_id.hours_per_day + if ot_h_d and (day_hours[iso_date] + hours) > ot_h_d: + if day_hours[iso_date] >= ot_h_d: + # no time is regular time + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type.overtime_work_type_id][0] += 1.0 + result[work_type.overtime_work_type_id][1] += hours + result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier + else: + remaining_regular_hours = ot_h_d - day_hours[iso_date] + if remaining_regular_hours - hours < 0.0: + # some time is regular time + regular_hours = remaining_regular_hours + overtime_hours = hours - remaining_regular_hours + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type][0] += 1.0 + result[work_type][1] += regular_hours + result[work_type.overtime_work_type_id][1] += overtime_hours + result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier + else: + # all time is regular time + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type][0] += 1.0 + result[work_type][1] += hours + elif ot_h_w: + if week_hours[week] > ot_h_w: + # no time is regular time + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type.overtime_work_type_id][0] += 1.0 + result[work_type.overtime_work_type_id][1] += hours + result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier + else: + remaining_regular_hours = ot_h_w - week_hours[week] + if remaining_regular_hours - hours < 0.0: + # some time is regular time + regular_hours = remaining_regular_hours + overtime_hours = hours - remaining_regular_hours + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type][0] += 1.0 + result[work_type][1] += regular_hours + result[work_type.overtime_work_type_id][1] += overtime_hours + result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier + else: + # all time is regular time + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type][0] += 1.0 + result[work_type][1] += hours + else: + # all time is regular time + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type][0] += 1.0 + result[work_type][1] += hours + else: + if iso_date not in iso_days: + iso_days.add(iso_date) + result[work_type][0] += 1.0 + result[work_type][1] += hours + # Always + day_hours[iso_date] += hours + week_hours[week] += hours + return result diff --git a/hr_payroll_overtime/models/hr_work_entry.py b/hr_payroll_overtime/models/hr_work_entry.py new file mode 100644 index 00000000..bef38a6c --- /dev/null +++ b/hr_payroll_overtime/models/hr_work_entry.py @@ -0,0 +1,21 @@ +from odoo import fields, models + + +class HRWorkEntryType(models.Model): + _inherit = 'hr.work.entry.type' + + overtime_work_type_id = fields.Many2one('hr.work.entry.type', string='Overtime Work Type') + overtime_type_id = fields.Many2one('hr.work.entry.overtime.type', string='Overtime Rules') + + +class HRWorkEntryOvertime(models.Model): + _name = 'hr.work.entry.overtime.type' + _description = 'Overtime Rules' + + name = fields.Char(string='Name') + hours_per_day = fields.Float(string='Hours/Day', + help='Number of hours worked in a single day to trigger overtime.') + hours_per_week = fields.Float(string='Hours/Week', + help='Number of hours worked in a week to trigger overtime.') + multiplier = fields.Float(string='Multiplier', + help='Rate for when overtime is reached.') diff --git a/hr_payroll_overtime/models/resource_calendar.py b/hr_payroll_overtime/models/resource_calendar.py new file mode 100644 index 00000000..f1010164 --- /dev/null +++ b/hr_payroll_overtime/models/resource_calendar.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class ResourceCalendar(models.Model): + _inherit = 'resource.calendar' + + day_week_start = fields.Selection([ + ('1', 'Monday'), + ('2', 'Tuesday'), + ('3', 'Wednesday'), + ('4', 'Thursday'), + ('5', 'Friday'), + ('6', 'Saturday'), + ('7', 'Sunday'), + ], string='Day Week Starts', required=True, default='1') diff --git a/hr_payroll_overtime/security/ir.model.access.csv b/hr_payroll_overtime/security/ir.model.access.csv new file mode 100644 index 00000000..722691ff --- /dev/null +++ b/hr_payroll_overtime/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_work_entry_overtime_type,access_hr_work_entry_overtime_type,model_hr_work_entry_overtime_type,base.group_user,1,0,0,0 +manage_hr_work_entry_overtime_type,manage_hr_work_entry_overtime_type,model_hr_work_entry_overtime_type,hr_payroll.group_hr_payroll_manager,1,1,1,1 diff --git a/hr_payroll_overtime/tests/__init__.py b/hr_payroll_overtime/tests/__init__.py new file mode 100644 index 00000000..304ffd07 --- /dev/null +++ b/hr_payroll_overtime/tests/__init__.py @@ -0,0 +1 @@ +from . import test_overtime diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py new file mode 100644 index 00000000..6d6f0804 --- /dev/null +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -0,0 +1,271 @@ +from odoo.tests import common + + +class TestOvertime(common.TransactionCase): + + def setUp(self): + super().setUp() + self.overtime_rules = self.env['hr.work.entry.overtime.type'].create({ + 'name': 'Test', + 'hours_per_week': 40.0, + 'multiplier': 1.5, + }) + self.work_type_overtime = self.env['hr.work.entry.type'].create({ + 'name': 'Test Overtime', + 'code': 'TEST_OT' + }) + self.work_type = self.env['hr.work.entry.type'].create({ + 'name': 'Test', + 'code': 'TEST', + 'overtime_type_id': self.overtime_rules.id, + 'overtime_work_type_id': self.work_type_overtime.id, + }) + self.employee = self.env.ref('hr.employee_hne') + self.payslip = self.env['hr.payslip'].create({ + 'name': 'test slip', + 'employee_id': self.employee.id, + 'date_from': '2020-06-11', + 'date_to': '2020-06-15', + }) + + def test_02_overtime_aggregation(self): + # 38 hours in 1 week + work_data = [ + ((2020, 24, 1), [ + (self.work_type, 4.0, None), + (self.work_type, 4.0, None), + ]), + ((2020, 24, 2), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 3), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 4), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertTrue(self.work_type_overtime not in result_data) + self.assertEqual(result_data[self.work_type][0], 4) + self.assertEqual(result_data[self.work_type][1], 38.0) + + # 48 hours in 1 week + work_data += [ + ((2020, 24, 5), [ + (self.work_type, 10.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 5) + self.assertEqual(result_data[self.work_type][1], 40.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 8.0) + + # 52 hours in 1 week + work_data += [ + ((2020, 24, 6), [ + (self.work_type, 4.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 5) + self.assertEqual(result_data[self.work_type][1], 40.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 1) + self.assertEqual(result_data[self.work_type_overtime][1], 12.0) + + # reset and make two weeks + # 38 hours in 1 week x 2 + input_work_data = [ + ((2020, 24, 1), [ + (self.work_type, 4.0, None), + (self.work_type, 4.0, None), + ]), + ((2020, 24, 2), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 3), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 4), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + work_data = [] + for data in input_work_data: + work_data.append(data) + work_data.append(((data[0][0], data[0][1]+1, data[0][2]), data[1])) + + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertTrue(self.work_type_overtime not in result_data) + self.assertEqual(result_data[self.work_type][0], 8) + self.assertEqual(result_data[self.work_type][1], 76.0) + + # 48 hours in 1 week, 38 hours in 1 week + work_data += [ + ((2020, 24, 5), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 9) + self.assertEqual(result_data[self.work_type][1], 78.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 8.0) + + # 52 hours in 1 week, 38 hours in 1 week + work_data += [ + ((2020, 24, 6), [ + (self.work_type, 4.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 9) + self.assertEqual(result_data[self.work_type][1], 78.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 1) + self.assertEqual(result_data[self.work_type_overtime][1], 12.0) + + def test_03_overtime_aggregation_week_start(self): + self.employee.resource_calendar_id.day_week_start = '7' + self.test_02_overtime_aggregation() + + def test_10_overtime_aggregation_daily(self): + self.overtime_rules.hours_per_day = 8.0 + self.overtime_rules.multiplier_per_day = 1.5 + # 38 hours in 1 week + work_data = [ + ((2020, 24, 1), [ + (self.work_type, 4.0, None), + (self.work_type, 4.0, None), + ]), + ((2020, 24, 2), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 3), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 4), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 4) + self.assertEqual(result_data[self.work_type][1], 32.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 6.0) + + # 48 hours in 1 week + work_data += [ + ((2020, 24, 5), [ + (self.work_type, 10.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 5) + self.assertEqual(result_data[self.work_type][1], 40.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 8.0) + + # 52 hours in 1 week + work_data += [ + ((2020, 24, 6), [ + (self.work_type, 4.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 5) + self.assertEqual(result_data[self.work_type][1], 40.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 1) + self.assertEqual(result_data[self.work_type_overtime][1], 12.0) + + # reset and make two weeks + # 38 hours in 1 week x 2 + input_work_data = [ + ((2020, 24, 1), [ + (self.work_type, 4.0, None), + (self.work_type, 4.0, None), + ]), + ((2020, 24, 2), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 3), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 4), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + work_data = [] + for data in input_work_data: + work_data.append(data) + work_data.append(((data[0][0], data[0][1]+1, data[0][2]), data[1])) + + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type][0], 8) + self.assertEqual(result_data[self.work_type][1], 64.0) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 12.0) + + # 48 hours in 1 week, 38 hours in 1 week + work_data += [ + ((2020, 24, 5), [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 9) + self.assertEqual(result_data[self.work_type][1], 70.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 16.0) + + # 52 hours in 1 week, 38 hours in 1 week + work_data += [ + ((2020, 24, 6), [ + (self.work_type, 4.0, None), + ]), + ] + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 9) + self.assertEqual(result_data[self.work_type][1], 70.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 1) + self.assertEqual(result_data[self.work_type_overtime][1], 20.0) + + def test_11_overtime_aggregation_daily_week_start(self): + self.employee.resource_calendar_id.day_week_start = '7' + self.test_10_overtime_aggregation_daily() diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml new file mode 100644 index 00000000..e543a70e --- /dev/null +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -0,0 +1,58 @@ + + + + + + hr.work.entry.type.form.inherit + hr.work.entry.type + + + + + + + + + + + + + + hr.work.entry.overtime.type.tree + hr.work.entry.overtime.type + + + + + + + + + + + + hr.work.entry.overtime.type.search + hr.work.entry.overtime.type + + + + + + + + + Overtime Rules + hr.work.entry.overtime.type + tree,form + +

+ No Overtime Rules +

+
+
+ + + +
\ No newline at end of file diff --git a/hr_payroll_overtime/views/resource_calendar_views.xml b/hr_payroll_overtime/views/resource_calendar_views.xml new file mode 100644 index 00000000..444dcc16 --- /dev/null +++ b/hr_payroll_overtime/views/resource_calendar_views.xml @@ -0,0 +1,15 @@ + + + + + resource.calendar.form.inherit + resource.calendar + + + + + + + + + \ No newline at end of file From af837721f51918fa42b3de3ea068fdcf85d6eac7 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Mon, 5 Oct 2020 13:05:51 -0700 Subject: [PATCH 02/12] [IMP] hr_payroll_overtime: refactor calculation to allow 'recursion' or overtime on overtime Example. Lets say you have 8hr/day overtime at 1.5x, and 12hr/day overtime at 2x. Now you can create a 2x overtime rules for 12 hours/day, and use it as the overtime rules for the original overtime worktype. --- hr_payroll_overtime/__manifest__.py | 2 +- hr_payroll_overtime/models/hr_payslip.py | 121 +++++++++------------ hr_payroll_overtime/tests/test_overtime.py | 52 +++++++++ 3 files changed, 104 insertions(+), 71 deletions(-) diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py index 49e653f0..99e48601 100644 --- a/hr_payroll_overtime/__manifest__.py +++ b/hr_payroll_overtime/__manifest__.py @@ -1,7 +1,7 @@ { 'name': 'Payroll Overtime', 'description': 'Provide mechanisms to calculate overtime.', - 'version': '13.0.1.0.0', + 'version': '13.0.1.0.1', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py index 13667cc9..3b50cd7a 100644 --- a/hr_payroll_overtime/models/hr_payslip.py +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -32,75 +32,56 @@ class HRPayslip(models.Model): iso_days = set() for iso_date, entries in work_data: iso_date = _adjust_week(iso_date) - week = iso_date[1] for work_type, hours, _ in entries: - if work_type.overtime_work_type_id and work_type.overtime_type_id: - ot_h_w = work_type.overtime_type_id.hours_per_week - ot_h_d = work_type.overtime_type_id.hours_per_day - if ot_h_d and (day_hours[iso_date] + hours) > ot_h_d: - if day_hours[iso_date] >= ot_h_d: - # no time is regular time - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type.overtime_work_type_id][0] += 1.0 - result[work_type.overtime_work_type_id][1] += hours - result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier - else: - remaining_regular_hours = ot_h_d - day_hours[iso_date] - if remaining_regular_hours - hours < 0.0: - # some time is regular time - regular_hours = remaining_regular_hours - overtime_hours = hours - remaining_regular_hours - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type][0] += 1.0 - result[work_type][1] += regular_hours - result[work_type.overtime_work_type_id][1] += overtime_hours - result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier - else: - # all time is regular time - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type][0] += 1.0 - result[work_type][1] += hours - elif ot_h_w: - if week_hours[week] > ot_h_w: - # no time is regular time - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type.overtime_work_type_id][0] += 1.0 - result[work_type.overtime_work_type_id][1] += hours - result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier - else: - remaining_regular_hours = ot_h_w - week_hours[week] - if remaining_regular_hours - hours < 0.0: - # some time is regular time - regular_hours = remaining_regular_hours - overtime_hours = hours - remaining_regular_hours - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type][0] += 1.0 - result[work_type][1] += regular_hours - result[work_type.overtime_work_type_id][1] += overtime_hours - result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier - else: - # all time is regular time - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type][0] += 1.0 - result[work_type][1] += hours - else: - # all time is regular time - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type][0] += 1.0 - result[work_type][1] += hours - else: - if iso_date not in iso_days: - iso_days.add(iso_date) - result[work_type][0] += 1.0 - result[work_type][1] += hours - # Always - day_hours[iso_date] += hours - week_hours[week] += hours + self._aggregate_overtime_add_work_type_hours(work_type, hours, iso_date, result, iso_days, day_hours, week_hours) + return result + + def _aggregate_overtime_add_work_type_hours(self, work_type, hours, iso_date, working_aggregation, iso_days, day_hours, week_hours): + """ + :param work_type: work type of hours being added + :param hours: hours being added + :param iso_date: date hours were worked + :param working_aggregation: dict of work type hours as they are processed + :param iso_days: set of iso days already seen + :param day_hours: hours worked on iso dates already processed + :param week_hours: hours worked on iso week already processed + :return: + """ + week = iso_date[1] + if work_type.overtime_work_type_id and work_type.overtime_type_id: + ot_h_w = work_type.overtime_type_id.hours_per_week + ot_h_d = work_type.overtime_type_id.hours_per_day + + regular_hours = hours + # adjust the hours based on overtime conditions + if ot_h_d and (day_hours[iso_date] + hours) > ot_h_d: + # daily overtime in effect + remaining_hours = max(ot_h_d - day_hours[iso_date], 0.0) + regular_hours = min(remaining_hours, hours) + elif ot_h_w: + # not daily, but weekly limits.... + remaining_hours = max(ot_h_w - week_hours[week], 0.0) + regular_hours = min(remaining_hours, hours) + ot_hours = hours - regular_hours + if regular_hours: + if iso_date not in iso_days: + iso_days.add(iso_date) + working_aggregation[work_type][0] += 1.0 + working_aggregation[work_type][1] += regular_hours + day_hours[iso_date] += regular_hours + week_hours[week] += regular_hours + if ot_hours: + # we need to save this because it won't be set once it reenter, we won't know what the original + # overtime multiplier was + working_aggregation[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier + self._aggregate_overtime_add_work_type_hours(work_type.overtime_work_type_id, ot_hours, iso_date, + working_aggregation, iso_days, day_hours, week_hours) + else: + # No overtime, just needs added to set + if iso_date not in iso_days: + iso_days.add(iso_date) + working_aggregation[work_type][0] += 1.0 + working_aggregation[work_type][1] += hours + day_hours[iso_date] += hours + week_hours[week] += hours diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index 6d6f0804..808c073e 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -269,3 +269,55 @@ class TestOvertime(common.TransactionCase): def test_11_overtime_aggregation_daily_week_start(self): self.employee.resource_calendar_id.day_week_start = '7' self.test_10_overtime_aggregation_daily() + + def test_12_recursive_daily(self): + # recursive will use a second overtime + self.work_type_overtime2 = self.env['hr.work.entry.type'].create({ + 'name': 'Test Overtime 2', + 'code': 'TEST_OT2' + }) + self.overtime_rules2 = self.env['hr.work.entry.overtime.type'].create({ + 'name': 'Test2', + 'hours_per_week': 999.0, + 'hours_per_day': 12.0, + 'multiplier': 2.0, + }) + self.overtime_rules.hours_per_day = 8.0 + self.overtime_rules.multiplier_per_day = 1.5 + self.work_type_overtime.overtime_type_id = self.overtime_rules2 + self.work_type_overtime.overtime_work_type_id = self.work_type_overtime2 + + work_data = [ + ((2020, 24, 1), [ + # regular day + (self.work_type, 4.0, None), + (self.work_type, 4.0, None), + ]), + ((2020, 24, 2), [ + # 2hr overtime + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 3), [ + # 4hr overtime + (self.work_type, 6.0, None), + (self.work_type, 6.0, None), + ]), + ((2020, 24, 4), [ + # 4hr overtime + # 2hr overtime2 + (self.work_type, 6.0, None), + (self.work_type, 8.0, None), + ]), + ] + + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 4) + self.assertEqual(result_data[self.work_type][1], 32.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 10.0) + self.assertTrue(self.work_type_overtime2 in result_data) + self.assertEqual(result_data[self.work_type_overtime2][0], 0) + self.assertEqual(result_data[self.work_type_overtime2][1], 2.0) From 5c5de9d9c12e009f2f3b3ca4a57adb8c89acf811 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 6 Oct 2020 07:56:04 -0700 Subject: [PATCH 03/12] [IMP] hr_payroll_overtime: detect recursion and prevent the simplest type in the form view --- hr_payroll_overtime/models/hr_payslip.py | 17 ++++++-- hr_payroll_overtime/tests/test_overtime.py | 39 +++++++++++++++++++ .../views/hr_work_entry_views.xml | 8 +++- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py index 3b50cd7a..4e482bf9 100644 --- a/hr_payroll_overtime/models/hr_payslip.py +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -1,5 +1,6 @@ from collections import defaultdict from odoo import models +from odoo.exceptions import UserError class HRPayslip(models.Model): @@ -30,10 +31,15 @@ class HRPayslip(models.Model): day_hours = defaultdict(float) week_hours = defaultdict(float) iso_days = set() - for iso_date, entries in work_data: - iso_date = _adjust_week(iso_date) - for work_type, hours, _ in entries: - self._aggregate_overtime_add_work_type_hours(work_type, hours, iso_date, result, iso_days, day_hours, week_hours) + try: + for iso_date, entries in work_data: + iso_date = _adjust_week(iso_date) + for work_type, hours, _ in entries: + self._aggregate_overtime_add_work_type_hours(work_type, hours, iso_date, result, iso_days, day_hours, week_hours) + except RecursionError: + raise UserError('RecursionError raised. Ensure you have not overtime loops, you should have an ' + 'end work type that does not have any "overtime" version, and would be considered ' + 'the "highest overtime" work type and rate.') return result @@ -75,6 +81,9 @@ class HRPayslip(models.Model): # we need to save this because it won't be set once it reenter, we won't know what the original # overtime multiplier was working_aggregation[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier + if work_type == work_type.overtime_work_type_id: + # trivial infinite recursion + raise UserError('Work type %s (id %s) must not have itself as its next overtime type.' % (work_type.name, work_type.id)) self._aggregate_overtime_add_work_type_hours(work_type.overtime_work_type_id, ot_hours, iso_date, working_aggregation, iso_days, day_hours, week_hours) else: diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index 808c073e..6c5cf9c3 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -1,4 +1,5 @@ from odoo.tests import common +from odoo.exceptions import UserError class TestOvertime(common.TransactionCase): @@ -321,3 +322,41 @@ class TestOvertime(common.TransactionCase): self.assertTrue(self.work_type_overtime2 in result_data) self.assertEqual(result_data[self.work_type_overtime2][0], 0) self.assertEqual(result_data[self.work_type_overtime2][1], 2.0) + + def test_13_recursive_infinite_trivial(self): + # recursive should will use a second overtime, but not this time! + self.overtime_rules.hours_per_day = 8.0 + self.overtime_rules.multiplier_per_day = 1.5 + self.work_type.overtime_type_id = self.overtime_rules + # overtime goes to itself + self.work_type.overtime_work_type_id = self.work_type + + work_data = [ + ((2020, 24, 2), [ + # 2hr overtime + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + + with self.assertRaises(UserError): + result_data = self.payslip.aggregate_overtime(work_data) + + def test_14_recursive_infinite_loop(self): + # recursive will use a second overtime, but not this time! + self.overtime_rules.hours_per_day = 8.0 + self.overtime_rules.multiplier_per_day = 1.5 + self.work_type_overtime.overtime_type_id = self.overtime_rules + # overtime goes back to worktype + self.work_type_overtime.overtime_work_type_id = self.work_type + + work_data = [ + ((2020, 24, 2), [ + # 2hr overtime + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + + with self.assertRaises(UserError): + result_data = self.payslip.aggregate_overtime(work_data) diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml index e543a70e..cbdb58f3 100644 --- a/hr_payroll_overtime/views/hr_work_entry_views.xml +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -9,8 +9,12 @@ - - + + From ab1fbc9a008e66238bb3b43ab5e23c46e3febcd4 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 6 Oct 2020 13:50:48 -0700 Subject: [PATCH 04/12] [IMP] hr_payroll_overtime: implement date or day of week overrides to overtime rules --- hr_payroll_overtime/models/hr_payslip.py | 21 +++- hr_payroll_overtime/models/hr_work_entry.py | 42 +++++++- .../models/resource_calendar.py | 21 ++-- .../security/ir.model.access.csv | 2 + hr_payroll_overtime/tests/test_overtime.py | 95 ++++++++++++++++++- .../views/hr_work_entry_views.xml | 32 ++++++- 6 files changed, 196 insertions(+), 17 deletions(-) diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py index 4e482bf9..0ff4d2a0 100644 --- a/hr_payroll_overtime/models/hr_payslip.py +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -78,13 +78,24 @@ class HRPayslip(models.Model): day_hours[iso_date] += regular_hours week_hours[week] += regular_hours if ot_hours: + overtime_work_type = work_type.overtime_work_type_id + multiplier = work_type.overtime_type_id.multiplier + override = work_type.overtime_type_id.override_for_iso_date(iso_date) + if work_type == overtime_work_type: + # trivial infinite recursion + raise UserError('Work type "%s" (id %s) must not have itself as its next overtime type.' % (work_type.name, work_type.id)) + if override: + overtime_work_type = override.work_type_id + multiplier = override.multiplier + if work_type == overtime_work_type: + # trivial infinite recursion from override + raise UserError('Work type "%s" (id %s) must not have itself as its next overtime type. ' + 'This occurred due to an override "%s" from overtime rules "%s".' % ( + work_type.name, work_type.id, override.name, work_type.overtime_type_id.name)) # we need to save this because it won't be set once it reenter, we won't know what the original # overtime multiplier was - working_aggregation[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier - if work_type == work_type.overtime_work_type_id: - # trivial infinite recursion - raise UserError('Work type %s (id %s) must not have itself as its next overtime type.' % (work_type.name, work_type.id)) - self._aggregate_overtime_add_work_type_hours(work_type.overtime_work_type_id, ot_hours, iso_date, + working_aggregation[overtime_work_type][2] = multiplier + self._aggregate_overtime_add_work_type_hours(overtime_work_type, ot_hours, iso_date, working_aggregation, iso_days, day_hours, week_hours) else: # No overtime, just needs added to set diff --git a/hr_payroll_overtime/models/hr_work_entry.py b/hr_payroll_overtime/models/hr_work_entry.py index bef38a6c..3225da5b 100644 --- a/hr_payroll_overtime/models/hr_work_entry.py +++ b/hr_payroll_overtime/models/hr_work_entry.py @@ -1,4 +1,7 @@ -from odoo import fields, models +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from .resource_calendar import WEEKDAY_SELECTION class HRWorkEntryType(models.Model): @@ -19,3 +22,40 @@ class HRWorkEntryOvertime(models.Model): help='Number of hours worked in a week to trigger overtime.') multiplier = fields.Float(string='Multiplier', help='Rate for when overtime is reached.') + override_ids = fields.One2many('hr.work.entry.overtime.type.override', 'overtime_type_id', + string='Overrides', + help='Override lines with a Date will be considered before Day of Week.') + + def override_for_iso_date(self, iso_date): + return self.override_ids.iso_date_applies(iso_date) + + +class HRWorkEntryOvertimeOverride(models.Model): + _name = 'hr.work.entry.overtime.type.override' + _description = 'Overtime Rule Override' + _order = 'date desc, day_of_week' + + name = fields.Char(string='Description') + overtime_type_id = fields.Many2one('hr.work.entry.overtime.type', + string='Overtime Rules') + work_type_id = fields.Many2one('hr.work.entry.type', string='Overtime Work Type', required=True, + help='Distinct Work Type for this. Given the different rate, it should ' + ' be different from other Overtime Work Types (because payslips ' + 'should only have one line/rate per work type).') + multiplier = fields.Float(string='Multiplier', + help='Rate for when overtime is reached.') + day_of_week = fields.Selection(WEEKDAY_SELECTION, string='Day of Week') + date = fields.Date(string='Date') + + @api.constrains('day_of_week', 'date') + def _constrain_days(self): + for override in self: + if override.day_of_week and override.date: + raise ValidationError('An override should only have a Date OR Day of Week.') + + def iso_date_applies(self, iso_date): + for override in self: + if override.date and override.date.isocalendar() == iso_date: + return override + if int(override.day_of_week) == iso_date[2]: + return override diff --git a/hr_payroll_overtime/models/resource_calendar.py b/hr_payroll_overtime/models/resource_calendar.py index f1010164..6d63a103 100644 --- a/hr_payroll_overtime/models/resource_calendar.py +++ b/hr_payroll_overtime/models/resource_calendar.py @@ -1,15 +1,18 @@ from odoo import fields, models +WEEKDAY_SELECTION = [ + ('1', 'Monday'), + ('2', 'Tuesday'), + ('3', 'Wednesday'), + ('4', 'Thursday'), + ('5', 'Friday'), + ('6', 'Saturday'), + ('7', 'Sunday'), +] + + class ResourceCalendar(models.Model): _inherit = 'resource.calendar' - day_week_start = fields.Selection([ - ('1', 'Monday'), - ('2', 'Tuesday'), - ('3', 'Wednesday'), - ('4', 'Thursday'), - ('5', 'Friday'), - ('6', 'Saturday'), - ('7', 'Sunday'), - ], string='Day Week Starts', required=True, default='1') + day_week_start = fields.Selection(WEEKDAY_SELECTION, string='Day Week Starts', required=True, default='1') diff --git a/hr_payroll_overtime/security/ir.model.access.csv b/hr_payroll_overtime/security/ir.model.access.csv index 722691ff..03aadd5f 100644 --- a/hr_payroll_overtime/security/ir.model.access.csv +++ b/hr_payroll_overtime/security/ir.model.access.csv @@ -1,3 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_hr_work_entry_overtime_type,access_hr_work_entry_overtime_type,model_hr_work_entry_overtime_type,base.group_user,1,0,0,0 manage_hr_work_entry_overtime_type,manage_hr_work_entry_overtime_type,model_hr_work_entry_overtime_type,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_hr_work_entry_overtime_type_override,access_hr_work_entry_overtime_type_override,model_hr_work_entry_overtime_type_override,base.group_user,1,0,0,0 +manage_hr_work_entry_overtime_type_override,manage_hr_work_entry_overtime_type_override,model_hr_work_entry_overtime_type_override,hr_payroll.group_hr_payroll_manager,1,1,1,1 \ No newline at end of file diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index 6c5cf9c3..d934f78e 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -1,5 +1,7 @@ +from datetime import date + from odoo.tests import common -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError class TestOvertime(common.TransactionCase): @@ -360,3 +362,94 @@ class TestOvertime(common.TransactionCase): with self.assertRaises(UserError): result_data = self.payslip.aggregate_overtime(work_data) + + def test_15_override_day_of_week(self): + iso_date = (2020, 24, 1) + self.overtime_rules.hours_per_day = 8.0 + self.overtime_rules.multiplier_per_day = 1.5 + work_data = [ + (iso_date, [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 8.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 2.0) + self.assertEqual(result_data[self.work_type_overtime][2], 1.5) + + + # Now lets make an override line + self.overtime_rules.write({ + 'override_ids': [(0, 0, { + 'name': 'Day 1 Override', + 'multiplier': 2.0, + 'day_of_week': str(iso_date[2]), + 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice + })] + }) + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 8.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 2.0) + self.assertEqual(result_data[self.work_type_overtime][2], 2.0) # rate 2x + + def test_16_override_date(self): + iso_date = (2020, 24, 1) + self.overtime_rules.hours_per_day = 8.0 + self.overtime_rules.multiplier_per_day = 1.5 + work_data = [ + (iso_date, [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 8.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 2.0) + self.assertEqual(result_data[self.work_type_overtime][2], 1.5) + + # Now lets make a specific date override + self.overtime_rules.write({ + 'override_ids': [(0, 0, { + 'name': 'Day (2020, 24, 1) Override', + 'multiplier': 3.0, + 'date': date(2020, 6, 8), # date.fromisocalendar(*iso_date), + 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice + })] + }) + self.overtime_rules.flush() + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 8.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 2.0) + self.assertEqual(result_data[self.work_type_overtime][2], 3.0) # rate 3x + + def test_17_override_config(self): + with self.assertRaises(ValidationError): + self.overtime_rules.write({ + 'override_ids': [(0, 0, { + 'name': 'Day (2020, 24, 1) Override', + 'multiplier': 3.0, + # cannot have both date and day_of_week + 'date': date(2020, 6, 8), + 'day_of_week': '1', + 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice + })] + }) diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml index cbdb58f3..ac304e33 100644 --- a/hr_payroll_overtime/views/hr_work_entry_views.xml +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -25,7 +25,7 @@ hr.work.entry.overtime.type.tree hr.work.entry.overtime.type - + @@ -34,6 +34,36 @@ + + hr.work.entry.overtime.type.form + hr.work.entry.overtime.type + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ hr.work.entry.overtime.type.search hr.work.entry.overtime.type From 4aa89786cf9c7512628cb268bce9a0692cdef60f Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 26 Nov 2020 05:21:54 -0800 Subject: [PATCH 05/12] [MIG] hr_payroll_overtime: for Odoo Enterprise 14.0 --- hr_payroll_overtime/__manifest__.py | 2 +- hr_payroll_overtime/tests/test_overtime.py | 12 ++++++------ hr_payroll_overtime/views/hr_work_entry_views.xml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py index 99e48601..0895cf20 100644 --- a/hr_payroll_overtime/__manifest__.py +++ b/hr_payroll_overtime/__manifest__.py @@ -1,7 +1,7 @@ { 'name': 'Payroll Overtime', 'description': 'Provide mechanisms to calculate overtime.', - 'version': '13.0.1.0.1', + 'version': '14.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index d934f78e..6160f1b2 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -151,7 +151,7 @@ class TestOvertime(common.TransactionCase): def test_10_overtime_aggregation_daily(self): self.overtime_rules.hours_per_day = 8.0 - self.overtime_rules.multiplier_per_day = 1.5 + self.overtime_rules.multiplier = 1.5 # 38 hours in 1 week work_data = [ ((2020, 24, 1), [ @@ -286,7 +286,7 @@ class TestOvertime(common.TransactionCase): 'multiplier': 2.0, }) self.overtime_rules.hours_per_day = 8.0 - self.overtime_rules.multiplier_per_day = 1.5 + self.overtime_rules.multiplier = 1.5 self.work_type_overtime.overtime_type_id = self.overtime_rules2 self.work_type_overtime.overtime_work_type_id = self.work_type_overtime2 @@ -328,7 +328,7 @@ class TestOvertime(common.TransactionCase): def test_13_recursive_infinite_trivial(self): # recursive should will use a second overtime, but not this time! self.overtime_rules.hours_per_day = 8.0 - self.overtime_rules.multiplier_per_day = 1.5 + self.overtime_rules.multiplier = 1.5 self.work_type.overtime_type_id = self.overtime_rules # overtime goes to itself self.work_type.overtime_work_type_id = self.work_type @@ -347,7 +347,7 @@ class TestOvertime(common.TransactionCase): def test_14_recursive_infinite_loop(self): # recursive will use a second overtime, but not this time! self.overtime_rules.hours_per_day = 8.0 - self.overtime_rules.multiplier_per_day = 1.5 + self.overtime_rules.multiplier = 1.5 self.work_type_overtime.overtime_type_id = self.overtime_rules # overtime goes back to worktype self.work_type_overtime.overtime_work_type_id = self.work_type @@ -366,7 +366,7 @@ class TestOvertime(common.TransactionCase): def test_15_override_day_of_week(self): iso_date = (2020, 24, 1) self.overtime_rules.hours_per_day = 8.0 - self.overtime_rules.multiplier_per_day = 1.5 + self.overtime_rules.multiplier = 1.5 work_data = [ (iso_date, [ (self.work_type, 4.0, None), @@ -405,7 +405,7 @@ class TestOvertime(common.TransactionCase): def test_16_override_date(self): iso_date = (2020, 24, 1) self.overtime_rules.hours_per_day = 8.0 - self.overtime_rules.multiplier_per_day = 1.5 + self.overtime_rules.multiplier = 1.5 work_data = [ (iso_date, [ (self.work_type, 4.0, None), diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml index ac304e33..c60f693a 100644 --- a/hr_payroll_overtime/views/hr_work_entry_views.xml +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -87,6 +87,6 @@ + sequence="11" parent="hr_work_entry_contract.menu_hr_work_entry_confirguration"/> \ No newline at end of file From 3da66730d112a713e4c05a12db91d053e2d4622d Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 27 Nov 2020 14:23:33 -0800 Subject: [PATCH 06/12] [IMP] hr_payroll_overtime: refactor/improve API (for `hr_payroll_attendance`) --- hr_payroll_overtime/__manifest__.py | 2 +- hr_payroll_overtime/models/hr_payslip.py | 60 ++++++++++++++++++++-- hr_payroll_overtime/tests/test_overtime.py | 38 +++++++------- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py index 0895cf20..fc685a77 100644 --- a/hr_payroll_overtime/__manifest__.py +++ b/hr_payroll_overtime/__manifest__.py @@ -13,7 +13,7 @@ 'views/resource_calendar_views.xml', ], 'depends': [ - 'hr_payroll', + 'hr_payroll_hibou', 'hr_work_entry', ], } diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py index 0ff4d2a0..9031b3a4 100644 --- a/hr_payroll_overtime/models/hr_payslip.py +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -1,17 +1,54 @@ from collections import defaultdict -from odoo import models +from odoo import api, fields, models from odoo.exceptions import UserError class HRPayslip(models.Model): _inherit = 'hr.payslip' - def aggregate_overtime(self, work_data, day_week_start=None): + def _get_worked_day_lines_values(self, domain=None): + worked_day_lines_values = super()._get_worked_day_lines_values(domain=domain) + return self._process_worked_day_lines_values(worked_day_lines_values, domaian=domain) + + def _process_worked_day_lines_values(self, worked_day_lines_values, domaian=None): + if not self.state in ('draft', 'verify'): + return worked_day_lines_values + + worked_day_lines_values = self._filter_worked_day_lines_values(worked_day_lines_values) + work_data = self._pre_aggregate_work_data() + work_data = self._post_aggregate_work_data(work_data) + processed_data = self._aggregate_overtime(work_data) + worked_day_lines_values += [{ + 'number_of_days': data[0], + 'number_of_hours': data[1], + 'rate': data[2], + 'contract_id': self.contract_id.id, + 'work_entry_type_id': work_type.id, + } for work_type, data in processed_data.items()] + return worked_day_lines_values + + def _filter_worked_day_lines_values(self, worked_day_lines_values): + # e.g. maybe you want to remove the stock 'WORK100' lines + # returns new worked_day_lines_values + return worked_day_lines_values + + def _pre_aggregate_work_data(self): + # returns dict(iso_date: list(tuple(hr.work.entry.type(), hours, original_record)) + return defaultdict(list) + + def _post_aggregate_work_data(self, work_data): + # takes pre_aggregate data format and converts it. + # this is to simplify algorithm and guarantee ordered by iso_date semantics + # work_data: dict(iso_date: list(tuple(hr.work.entry.type(), hours, original_record)) + # returns: list(tuple(iso_date, list(tuple(hr.work.entry.type(), hours, original_record)) + return [(iso_date, work_data[iso_date]) for iso_date in sorted(work_data.keys())] + + def _aggregate_overtime(self, work_data, day_week_start=None): """ :param work_data: list(tuple(iso_date, list(tuple(hr.work.entry.type(), hours, original_record)) :param day_week_start: day of the week to start (otherwise employee's resource calendar start day of week) - :return: dict(hr.work.entry.type(): list(days_worked, hours_worked, )) + :return: dict(hr.work.entry.type(): list(days_worked, hours_worked, rate)) """ if not day_week_start: if self.employee_id.resource_calendar_id.day_week_start: @@ -105,3 +142,20 @@ class HRPayslip(models.Model): working_aggregation[work_type][1] += hours day_hours[iso_date] += hours week_hours[week] += hours + + +class HrPayslipWorkedDays(models.Model): + _inherit = 'hr.payslip.worked_days' + + rate = fields.Float(string='Rate', default=1.0) + + @api.depends('is_paid', 'number_of_hours', 'payslip_id', 'payslip_id.normal_wage', 'payslip_id.sum_worked_hours', 'rate') + def _compute_amount(self): + for worked_days in self: + if not worked_days.contract_id: + worked_days.amount = 0 + continue + if worked_days.payslip_id.wage_type == "hourly": + worked_days.amount = worked_days.payslip_id.contract_id._get_contract_wage(work_type=worked_days.work_entry_type_id) * worked_days.number_of_hours * worked_days.rate if worked_days.is_paid else 0 + else: + worked_days.amount = worked_days.payslip_id.normal_wage * worked_days.number_of_hours / (worked_days.payslip_id.sum_worked_hours or 1) if worked_days.is_paid else 0 diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index 6160f1b2..1c4023e0 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -51,7 +51,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 6.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertTrue(self.work_type_overtime not in result_data) self.assertEqual(result_data[self.work_type][0], 4) @@ -63,7 +63,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 10.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 5) self.assertEqual(result_data[self.work_type][1], 40.0) @@ -77,7 +77,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 4.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 5) self.assertEqual(result_data[self.work_type][1], 40.0) @@ -110,7 +110,7 @@ class TestOvertime(common.TransactionCase): work_data.append(data) work_data.append(((data[0][0], data[0][1]+1, data[0][2]), data[1])) - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertTrue(self.work_type_overtime not in result_data) self.assertEqual(result_data[self.work_type][0], 8) @@ -123,7 +123,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 6.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 9) self.assertEqual(result_data[self.work_type][1], 78.0) @@ -137,7 +137,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 4.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 9) self.assertEqual(result_data[self.work_type][1], 78.0) @@ -171,7 +171,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 6.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 4) self.assertEqual(result_data[self.work_type][1], 32.0) @@ -185,7 +185,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 10.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 5) self.assertEqual(result_data[self.work_type][1], 40.0) @@ -199,7 +199,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 4.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 5) self.assertEqual(result_data[self.work_type][1], 40.0) @@ -232,7 +232,7 @@ class TestOvertime(common.TransactionCase): work_data.append(data) work_data.append(((data[0][0], data[0][1]+1, data[0][2]), data[1])) - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertTrue(self.work_type_overtime in result_data) self.assertEqual(result_data[self.work_type][0], 8) @@ -247,7 +247,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 6.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 9) self.assertEqual(result_data[self.work_type][1], 70.0) @@ -261,7 +261,7 @@ class TestOvertime(common.TransactionCase): (self.work_type, 4.0, None), ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 9) self.assertEqual(result_data[self.work_type][1], 70.0) @@ -314,7 +314,7 @@ class TestOvertime(common.TransactionCase): ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 4) self.assertEqual(result_data[self.work_type][1], 32.0) @@ -342,7 +342,7 @@ class TestOvertime(common.TransactionCase): ] with self.assertRaises(UserError): - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) def test_14_recursive_infinite_loop(self): # recursive will use a second overtime, but not this time! @@ -361,7 +361,7 @@ class TestOvertime(common.TransactionCase): ] with self.assertRaises(UserError): - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) def test_15_override_day_of_week(self): iso_date = (2020, 24, 1) @@ -374,7 +374,7 @@ class TestOvertime(common.TransactionCase): ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 1) self.assertEqual(result_data[self.work_type][1], 8.0) @@ -393,7 +393,7 @@ class TestOvertime(common.TransactionCase): 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice })] }) - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 1) self.assertEqual(result_data[self.work_type][1], 8.0) @@ -413,7 +413,7 @@ class TestOvertime(common.TransactionCase): ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 1) self.assertEqual(result_data[self.work_type][1], 8.0) @@ -432,7 +432,7 @@ class TestOvertime(common.TransactionCase): })] }) self.overtime_rules.flush() - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 1) self.assertEqual(result_data[self.work_type][1], 8.0) From 0bbd1cfc37f0a0047910c22f0224a612b20ccbac Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Fri, 27 Nov 2020 16:30:23 -0800 Subject: [PATCH 07/12] [IMP] hr_payroll_overtime: expose the "Rate" field on Worked Day Lines --- hr_payroll_overtime/__manifest__.py | 1 + .../views/hr_payslip_views.xml | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 hr_payroll_overtime/views/hr_payslip_views.xml diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py index fc685a77..509838be 100644 --- a/hr_payroll_overtime/__manifest__.py +++ b/hr_payroll_overtime/__manifest__.py @@ -9,6 +9,7 @@ 'data': [ 'security/ir.model.access.csv', 'data/overtime_data.xml', + 'views/hr_payslip_views.xml', 'views/hr_work_entry_views.xml', 'views/resource_calendar_views.xml', ], diff --git a/hr_payroll_overtime/views/hr_payslip_views.xml b/hr_payroll_overtime/views/hr_payslip_views.xml new file mode 100644 index 00000000..7b5a2601 --- /dev/null +++ b/hr_payroll_overtime/views/hr_payslip_views.xml @@ -0,0 +1,19 @@ + + + + + + hr.payslip.form.inherit + hr.payslip + + + + + + + + + + + + From 0f66423e702b050841491b7ee5aedcaff6e00754 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 9 Dec 2020 15:21:29 -0800 Subject: [PATCH 08/12] [IMP] hr_payroll_overtime: refactor to abstract override class and use on Work Types themselves E.g. It is now possible to support "Sunday Pay" where before it was only possible to give "Sunday Overtime Pay" as an override to overtime itself. --- hr_payroll_overtime/models/hr_payslip.py | 13 ++++ hr_payroll_overtime/models/hr_work_entry.py | 64 ++++++++++++------- .../security/ir.model.access.csv | 4 +- hr_payroll_overtime/tests/test_overtime.py | 37 +++++++++++ .../views/hr_work_entry_views.xml | 10 +++ 5 files changed, 104 insertions(+), 24 deletions(-) diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py index 9031b3a4..538d9b9f 100644 --- a/hr_payroll_overtime/models/hr_payslip.py +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -91,6 +91,19 @@ class HRPayslip(models.Model): :param week_hours: hours worked on iso week already processed :return: """ + override = work_type.override_for_iso_date(iso_date) + if override: + new_work_type = override.work_type_id + multiplier = override.multiplier + if work_type == new_work_type: + # trivial infinite recursion from override + raise UserError('Work type "%s" (id %s) must not have itself as its override work type. ' + 'This occurred due to an override line "%s".' % ( + work_type.name, work_type.id, override.name)) + # update the work_type on this step. + work_type = new_work_type + working_aggregation[work_type][2] = multiplier + week = iso_date[1] if work_type.overtime_work_type_id and work_type.overtime_type_id: ot_h_w = work_type.overtime_type_id.hours_per_week diff --git a/hr_payroll_overtime/models/hr_work_entry.py b/hr_payroll_overtime/models/hr_work_entry.py index 3225da5b..f607fd32 100644 --- a/hr_payroll_overtime/models/hr_work_entry.py +++ b/hr_payroll_overtime/models/hr_work_entry.py @@ -4,11 +4,51 @@ from odoo.exceptions import ValidationError from .resource_calendar import WEEKDAY_SELECTION +class WorkEntryOverride(models.AbstractModel): + _name = 'hr.work.entry.type.override.abstract' + _order = 'date desc, day_of_week' + + name = fields.Char(string='Description') + work_type_id = fields.Many2one('hr.work.entry.type', string='Override Work Type', required=True, + help='Distinct Work Type for when this applies.') + multiplier = fields.Float(string='Multiplier', + help='Rate for override. E.g. maybe you have "Sunday Pay" at 2.0x') + day_of_week = fields.Selection(WEEKDAY_SELECTION, string='Day of Week') + date = fields.Date(string='Date') + + @api.constrains('day_of_week', 'date') + def _constrain_days(self): + for override in self: + if override.day_of_week and override.date: + raise ValidationError('An override should only have a Date OR Day of Week.') + + def iso_date_applies(self, iso_date): + for override in self: + if override.date and override.date.isocalendar() == iso_date: + return override + if int(override.day_of_week) == iso_date[2]: + return override + + class HRWorkEntryType(models.Model): _inherit = 'hr.work.entry.type' overtime_work_type_id = fields.Many2one('hr.work.entry.type', string='Overtime Work Type') overtime_type_id = fields.Many2one('hr.work.entry.overtime.type', string='Overtime Rules') + override_ids = fields.One2many('hr.work.entry.type.override', 'original_type_id', string='Overrides', + help='Override work entry type on payslip.') + + def override_for_iso_date(self, iso_date): + return self.override_ids.iso_date_applies(iso_date) + + +class HRWorkEntryTypeOverride(models.Model): + _name = 'hr.work.entry.type.override' + _inherit = 'hr.work.entry.type.override.abstract' + _description = 'Work Type Override' + + original_type_id = fields.Many2one('hr.work.entry.type', + string='Work Entry Type') class HRWorkEntryOvertime(models.Model): @@ -32,30 +72,8 @@ class HRWorkEntryOvertime(models.Model): class HRWorkEntryOvertimeOverride(models.Model): _name = 'hr.work.entry.overtime.type.override' + _inherit = 'hr.work.entry.type.override.abstract' _description = 'Overtime Rule Override' - _order = 'date desc, day_of_week' - name = fields.Char(string='Description') overtime_type_id = fields.Many2one('hr.work.entry.overtime.type', string='Overtime Rules') - work_type_id = fields.Many2one('hr.work.entry.type', string='Overtime Work Type', required=True, - help='Distinct Work Type for this. Given the different rate, it should ' - ' be different from other Overtime Work Types (because payslips ' - 'should only have one line/rate per work type).') - multiplier = fields.Float(string='Multiplier', - help='Rate for when overtime is reached.') - day_of_week = fields.Selection(WEEKDAY_SELECTION, string='Day of Week') - date = fields.Date(string='Date') - - @api.constrains('day_of_week', 'date') - def _constrain_days(self): - for override in self: - if override.day_of_week and override.date: - raise ValidationError('An override should only have a Date OR Day of Week.') - - def iso_date_applies(self, iso_date): - for override in self: - if override.date and override.date.isocalendar() == iso_date: - return override - if int(override.day_of_week) == iso_date[2]: - return override diff --git a/hr_payroll_overtime/security/ir.model.access.csv b/hr_payroll_overtime/security/ir.model.access.csv index 03aadd5f..0b0223eb 100644 --- a/hr_payroll_overtime/security/ir.model.access.csv +++ b/hr_payroll_overtime/security/ir.model.access.csv @@ -2,4 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_hr_work_entry_overtime_type,access_hr_work_entry_overtime_type,model_hr_work_entry_overtime_type,base.group_user,1,0,0,0 manage_hr_work_entry_overtime_type,manage_hr_work_entry_overtime_type,model_hr_work_entry_overtime_type,hr_payroll.group_hr_payroll_manager,1,1,1,1 access_hr_work_entry_overtime_type_override,access_hr_work_entry_overtime_type_override,model_hr_work_entry_overtime_type_override,base.group_user,1,0,0,0 -manage_hr_work_entry_overtime_type_override,manage_hr_work_entry_overtime_type_override,model_hr_work_entry_overtime_type_override,hr_payroll.group_hr_payroll_manager,1,1,1,1 \ No newline at end of file +manage_hr_work_entry_overtime_type_override,manage_hr_work_entry_overtime_type_override,model_hr_work_entry_overtime_type_override,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_hr_work_entry_type_override,access_hr_work_entry_type_override,model_hr_work_entry_type_override,base.group_user,1,0,0,0 +manage_hr_work_entry_type_override,manage_hr_work_entry_type_override,model_hr_work_entry_type_override,hr_payroll.group_hr_payroll_manager,1,1,1,1 diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index 1c4023e0..fc7e7eaf 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -453,3 +453,40 @@ class TestOvertime(common.TransactionCase): 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice })] }) + + def test_18_override_day_of_week_on_work_type(self): + iso_date = (2020, 24, 1) + iso_date2 = (2020, 24, 2) + + work_data = [ + (iso_date, [ + (self.work_type, 4.0, None), + ]), + (iso_date2, [ + (self.work_type, 4.0, None), + ]), + ] + + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 2) + self.assertEqual(result_data[self.work_type][1], 8.0) + + # Now lets make an override line + test_multiplier = 3.0 + self.work_type.write({ + 'override_ids': [(0, 0, { + 'name': 'Day 2 Override', + 'multiplier': test_multiplier, + 'day_of_week': str(iso_date[2]), + 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice + })] + }) + result_data = self.payslip.aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 4.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 1) + self.assertEqual(result_data[self.work_type_overtime][1], 4.0) + self.assertEqual(result_data[self.work_type_overtime][2], test_multiplier) diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml index c60f693a..774d61ed 100644 --- a/hr_payroll_overtime/views/hr_work_entry_views.xml +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -15,6 +15,16 @@ + + + + + + + + + + From 60fe0cc4a22b4697af2695ab3b68748bef571971 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 9 Dec 2020 16:00:55 -0800 Subject: [PATCH 09/12] [FIX] hr_payroll_overtime: tests after cherry-pick from 13.0 --- hr_payroll_overtime/tests/test_overtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index fc7e7eaf..55b93a68 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -467,7 +467,7 @@ class TestOvertime(common.TransactionCase): ]), ] - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 2) self.assertEqual(result_data[self.work_type][1], 8.0) @@ -482,7 +482,7 @@ class TestOvertime(common.TransactionCase): 'work_type_id': self.work_type_overtime.id, # Note that this wouldn't be good in practice })] }) - result_data = self.payslip.aggregate_overtime(work_data) + result_data = self.payslip._aggregate_overtime(work_data) self.assertTrue(self.work_type in result_data) self.assertEqual(result_data[self.work_type][0], 1) self.assertEqual(result_data[self.work_type][1], 4.0) From 75d6c97e6916003b03b84fb3fd9e42c2a76331a4 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 10 Dec 2020 09:40:06 -0800 Subject: [PATCH 10/12] [IMP] hr_payroll_overtime: implement exempt from overtime on contract --- hr_payroll_overtime/__manifest__.py | 1 + hr_payroll_overtime/models/__init__.py | 1 + hr_payroll_overtime/models/hr_contract.py | 8 +++++ hr_payroll_overtime/models/hr_payslip.py | 2 +- hr_payroll_overtime/tests/test_overtime.py | 30 +++++++++++++++++++ .../views/hr_contract_views.xml | 15 ++++++++++ 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 hr_payroll_overtime/models/hr_contract.py create mode 100644 hr_payroll_overtime/views/hr_contract_views.xml diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py index 509838be..85940d58 100644 --- a/hr_payroll_overtime/__manifest__.py +++ b/hr_payroll_overtime/__manifest__.py @@ -9,6 +9,7 @@ 'data': [ 'security/ir.model.access.csv', 'data/overtime_data.xml', + 'views/hr_contract_views.xml', 'views/hr_payslip_views.xml', 'views/hr_work_entry_views.xml', 'views/resource_calendar_views.xml', diff --git a/hr_payroll_overtime/models/__init__.py b/hr_payroll_overtime/models/__init__.py index 3fcc81d4..43cd2c4a 100644 --- a/hr_payroll_overtime/models/__init__.py +++ b/hr_payroll_overtime/models/__init__.py @@ -1,3 +1,4 @@ +from . import hr_contract from . import hr_payslip from . import hr_work_entry from . import resource_calendar diff --git a/hr_payroll_overtime/models/hr_contract.py b/hr_payroll_overtime/models/hr_contract.py new file mode 100644 index 00000000..e5847963 --- /dev/null +++ b/hr_payroll_overtime/models/hr_contract.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + is_overtime_exempt = fields.Boolean(string='Overtime Exempt', + help='e.g. Agriculture or farm work exempt under the US FLSA.') diff --git a/hr_payroll_overtime/models/hr_payslip.py b/hr_payroll_overtime/models/hr_payslip.py index 538d9b9f..e8bcd12e 100644 --- a/hr_payroll_overtime/models/hr_payslip.py +++ b/hr_payroll_overtime/models/hr_payslip.py @@ -105,7 +105,7 @@ class HRPayslip(models.Model): working_aggregation[work_type][2] = multiplier week = iso_date[1] - if work_type.overtime_work_type_id and work_type.overtime_type_id: + if not self.contract_id.is_overtime_exempt and work_type.overtime_work_type_id and work_type.overtime_type_id: ot_h_w = work_type.overtime_type_id.hours_per_week ot_h_d = work_type.overtime_type_id.hours_per_day diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index 55b93a68..d316e36c 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -24,9 +24,11 @@ class TestOvertime(common.TransactionCase): 'overtime_work_type_id': self.work_type_overtime.id, }) self.employee = self.env.ref('hr.employee_hne') + self.contract = self.employee.contract_ids.filtered(lambda l: l.state == 'open') self.payslip = self.env['hr.payslip'].create({ 'name': 'test slip', 'employee_id': self.employee.id, + 'contract_id': self.contract.id, 'date_from': '2020-06-11', 'date_to': '2020-06-15', }) @@ -490,3 +492,31 @@ class TestOvertime(common.TransactionCase): self.assertEqual(result_data[self.work_type_overtime][0], 1) self.assertEqual(result_data[self.work_type_overtime][1], 4.0) self.assertEqual(result_data[self.work_type_overtime][2], test_multiplier) + + def test_19_overtime_exempt(self): + iso_date = (2020, 24, 1) + self.overtime_rules.hours_per_day = 8.0 + work_data = [ + (iso_date, [ + (self.work_type, 4.0, None), + (self.work_type, 6.0, None), + ]), + ] + + result_data = self.payslip._aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 8.0) + self.assertTrue(self.work_type_overtime in result_data) + self.assertEqual(result_data[self.work_type_overtime][0], 0) + self.assertEqual(result_data[self.work_type_overtime][1], 2.0) + self.assertEqual(result_data[self.work_type_overtime][2], 1.5) + + self.payslip.contract_id.is_overtime_exempt = True + self.assertTrue(self.payslip.contract_id) + self.assertTrue(self.payslip.contract_id.is_overtime_exempt) + result_data = self.payslip._aggregate_overtime(work_data) + self.assertTrue(self.work_type in result_data) + self.assertEqual(result_data[self.work_type][0], 1) + self.assertEqual(result_data[self.work_type][1], 10.0) + self.assertTrue(self.work_type_overtime not in result_data) diff --git a/hr_payroll_overtime/views/hr_contract_views.xml b/hr_payroll_overtime/views/hr_contract_views.xml new file mode 100644 index 00000000..0e6b9a61 --- /dev/null +++ b/hr_payroll_overtime/views/hr_contract_views.xml @@ -0,0 +1,15 @@ + + + + + hr.contract.form.inherit + hr.contract + + + + + + + + + From f7b65bfa56fa5213963c271d2d0c0e5c103210a3 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 10 Dec 2020 14:01:00 -0800 Subject: [PATCH 11/12] [FIX] hr_payroll_overtime: colspan 4 squishes above fields Essentially, the colspan=4 here pushed the two fields above it into a strange view because it is a single group that is already 4 col wide. (cherry picked from commit d87fa8c9237bbcf9efbc95224a9db824c3ff2acc) --- hr_payroll_overtime/views/hr_work_entry_views.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml index 774d61ed..60b63a27 100644 --- a/hr_payroll_overtime/views/hr_work_entry_views.xml +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -16,7 +16,7 @@ domain="[('id', '!=', id)]" attrs="{'required': [('overtime_work_type_id', '!=', False)]}" /> - + From a2dc4999cde8b14854bb2423023830232cc7c46a Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Thu, 7 Oct 2021 09:28:20 -0700 Subject: [PATCH 12/12] [MIG] hr_payroll_overtime: to Odoo Enterprise 15.0 --- hr_payroll_overtime/__manifest__.py | 3 ++- hr_payroll_overtime/tests/test_overtime.py | 7 ++++++- hr_payroll_overtime/views/hr_work_entry_views.xml | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/hr_payroll_overtime/__manifest__.py b/hr_payroll_overtime/__manifest__.py index 85940d58..98f43fc3 100644 --- a/hr_payroll_overtime/__manifest__.py +++ b/hr_payroll_overtime/__manifest__.py @@ -1,7 +1,7 @@ { 'name': 'Payroll Overtime', 'description': 'Provide mechanisms to calculate overtime.', - 'version': '14.0.1.0.0', + 'version': '15.0.1.0.0', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', 'license': 'AGPL-3', @@ -17,5 +17,6 @@ 'depends': [ 'hr_payroll_hibou', 'hr_work_entry', + 'hr_work_entry_contract_enterprise', # only for menu! ], } diff --git a/hr_payroll_overtime/tests/test_overtime.py b/hr_payroll_overtime/tests/test_overtime.py index d316e36c..bdb60347 100644 --- a/hr_payroll_overtime/tests/test_overtime.py +++ b/hr_payroll_overtime/tests/test_overtime.py @@ -24,7 +24,12 @@ class TestOvertime(common.TransactionCase): 'overtime_work_type_id': self.work_type_overtime.id, }) self.employee = self.env.ref('hr.employee_hne') - self.contract = self.employee.contract_ids.filtered(lambda l: l.state == 'open') + self.contract = self.employee.contract_ids.create({ + 'name': 'testing contract', + 'employee_id': self.employee.id, + 'date_start': '2020-01-01', + 'wage': 2000.0, + }) self.payslip = self.env['hr.payslip'].create({ 'name': 'test slip', 'employee_id': self.employee.id, diff --git a/hr_payroll_overtime/views/hr_work_entry_views.xml b/hr_payroll_overtime/views/hr_work_entry_views.xml index 60b63a27..43ffd02a 100644 --- a/hr_payroll_overtime/views/hr_work_entry_views.xml +++ b/hr_payroll_overtime/views/hr_work_entry_views.xml @@ -97,6 +97,6 @@ + sequence="11" parent="hr_work_entry_contract_enterprise.menu_hr_work_entry_type_view"/> \ No newline at end of file