diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6992e60..220cd00 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,7 +76,7 @@ jobs: - name: Run tests run: oca_run_tests - name: Generate coverage.xml - run: coverage xml --include '*.py' + run: coverage xml --include '*.py' --omit '**/tests/*' - uses: codecov/codecov-action@v3 with: files: coverage.xml diff --git a/verdigado_attendance/__manifest__.py b/verdigado_attendance/__manifest__.py index 474e764..36360ff 100644 --- a/verdigado_attendance/__manifest__.py +++ b/verdigado_attendance/__manifest__.py @@ -41,10 +41,12 @@ "views/base_ical.xml", "views/hr_attendance_view.xml", "views/hr_attendance_report.xml", + "views/hr_employee.xml", "views/hr_leave_type.xml", "views/hr_leave.xml", "views/hr_menu_human_resources_configuration.xml", "views/menu.xml", + "views/res_config_settings.xml", ], "demo": [ "demo/res_users.xml", @@ -60,10 +62,12 @@ ], "web.assets_backend": [ "verdigado_attendance/static/src/scss/backend.scss", + "verdigado_attendance/static/src/js/hr_attendance.js", "verdigado_attendance/static/src/js/systray.esm.js", "verdigado_attendance/static/src/js/time_off_calendar.js", ], "web.assets_qweb": [ + "verdigado_attendance/static/src/xml/hr_attendance.xml", "verdigado_attendance/static/src/xml/hr_holidays.xml", "verdigado_attendance/static/src/xml/systray.xml", "verdigado_attendance/static/src/xml/time_off_calendar.xml", diff --git a/verdigado_attendance/models/__init__.py b/verdigado_attendance/models/__init__.py index 471de3d..d70ab65 100644 --- a/verdigado_attendance/models/__init__.py +++ b/verdigado_attendance/models/__init__.py @@ -5,6 +5,9 @@ from . import hr_attendance_break from . import hr_attendance_overtime from . import hr_attendance_report +from . import hr_employee from . import hr_leave from . import hr_leave_type +from . import res_config_settings from . import res_company +from . import res_users diff --git a/verdigado_attendance/models/hr_attendance.py b/verdigado_attendance/models/hr_attendance.py index 7bbab75..07833d6 100644 --- a/verdigado_attendance/models/hr_attendance.py +++ b/verdigado_attendance/models/hr_attendance.py @@ -1,6 +1,6 @@ # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from odoo import models +from odoo import _, fields, models from .hr_attendance_break import DatetimeWithoutSeconds @@ -10,14 +10,21 @@ class HrAttendance(models.Model): check_in = DatetimeWithoutSeconds() check_out = DatetimeWithoutSeconds() + apply_holiday_overtime_factor = fields.Boolean() def _update_overtime(self, employee_attendance_dates=None): - """Recreate missing overtime records""" + """ + Recreate missing overtime records to generate correct expected hours + Create adjustments for extra overtime by holiday factor + """ result = super()._update_overtime( employee_attendance_dates=employee_attendance_dates ) + if not self.exists(): + return result if employee_attendance_dates is None: employee_attendance_dates = self._get_attendances_dates() + missing_vals = [] for employee, attendance_dates in employee_attendance_dates.items(): dates = [attendance_date for _dummy, attendance_date in attendance_dates] @@ -28,10 +35,49 @@ def _update_overtime(self, employee_attendance_dates=None): ("date", "in", dates), ] ) - missing_vals += [ - {"employee_id": employee.id, "date": attendance_date} - for attendance_date in set(dates) - - set(existing_overtime.mapped("date")) - ] + for date in dates: + overtime = existing_overtime.filtered( + lambda x: x.date == date and not x.adjustment + ) + if not overtime: + # create overtime record for days where worked hours == expected hours + missing_vals += [{"employee_id": employee.id, "date": date}] + continue + holiday_overtime = existing_overtime.filtered( + lambda x: x.date == date and x.holiday_overtime_for_overtime_id + ) + factor = employee._get_effective_holiday_overtime_factor(date) + if factor != 1 and any(self.mapped("apply_holiday_overtime_factor")): + # create or update adjustment record to represent extra holiday overtime + duration = overtime.duration * factor - overtime.duration + if holiday_overtime: + holiday_overtime.sudo().duration = duration + else: + missing_vals.append( + { + "employee_id": employee.id, + "date": overtime.date, + "adjustment": True, + "duration": duration, + "holiday_overtime_for_overtime_id": overtime.id, + "note": _("Extra overtime from holiday factor (%.2f)") + % factor, + } + ) + else: + holiday_overtime.sudo().unlink() self.env["hr.attendance.overtime"].sudo().create(missing_vals) return result + + def write(self, vals): + """Make super update overtimes if we write the factor flag""" + if "apply_holiday_overtime_factor" in vals and not { + "employee_id", + "check_in", + "check_out", + } & set(vals): + result = True + for this in self: + result &= this.write(dict(vals, employee_id=this.employee_id.id)) + return result + return super().write(vals) diff --git a/verdigado_attendance/models/hr_attendance_overtime.py b/verdigado_attendance/models/hr_attendance_overtime.py index 7b9f3bc..68deaef 100644 --- a/verdigado_attendance/models/hr_attendance_overtime.py +++ b/verdigado_attendance/models/hr_attendance_overtime.py @@ -3,6 +3,7 @@ from datetime import datetime, time import pytz +from psycopg2.extensions import AsIs from odoo import api, fields, models @@ -11,6 +12,21 @@ class HrAttendanceOvertime(models.Model): _inherit = "hr.attendance.overtime" expected_hours = fields.Float(compute="_compute_expected_hours", store=True) + holiday_overtime_for_overtime_id = fields.Many2one( + "hr.attendance.overtime", ondelete="cascade" + ) + + def init(self): + """forbid more than one holiday overtime adjustment per day/employee""" + result = super().init() + self.env.cr.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS hr_attendance_overtime_holiday_adjustment + ON %s (employee_id, date) + WHERE adjustment IS TRUE AND holiday_overtime_for_overtime_id IS NOT NULL""", + (AsIs(self._table),), + ) + return result @api.depends("date", "employee_id", "duration") def _compute_expected_hours(self): diff --git a/verdigado_attendance/models/hr_employee.py b/verdigado_attendance/models/hr_employee.py new file mode 100644 index 0000000..e82d021 --- /dev/null +++ b/verdigado_attendance/models/hr_employee.py @@ -0,0 +1,54 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import fields, models + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + custom_holiday_overtime_factor = fields.Boolean( + help="Use a custom overtime factor for holidays/weekens instead of the company's", + groups="hr.group_hr_user", + ) + holiday_overtime_factor = fields.Float( + default=0, + help="When activated on holidays/weekends, overtime is multiplied with this factor", + groups="hr.group_hr_user", + ) + + def _get_effective_holiday_overtime_factor(self, date=None): + """Return an employee's effective overtime factor for some date""" + self.ensure_one() + self = self.sudo() + date = ( + date + or self.env["hr.attendance"]._get_day_start_and_day( + self, + fields.Datetime.now(), + )[1] + ) + return ( + ( + self.custom_holiday_overtime_factor + and self.holiday_overtime_factor + or self.company_id.holiday_overtime_factor + ) + if ( + date.isoweekday() >= 6 + or self.env["hr.holidays.public"].is_public_holiday(date, self.id) + ) + else 1 + ) + + def _attendance_action_change(self): + """React to default flag for overtime factor""" + result = super()._attendance_action_change() + if "default_apply_holiday_overtime_factor" in self.env.context: + result.write( + { + "apply_holiday_overtime_factor": self.env.context[ + "default_apply_holiday_overtime_factor" + ], + } + ) + return result diff --git a/verdigado_attendance/models/res_company.py b/verdigado_attendance/models/res_company.py index c309efe..9506e09 100644 --- a/verdigado_attendance/models/res_company.py +++ b/verdigado_attendance/models/res_company.py @@ -1,11 +1,16 @@ # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from odoo import models +from odoo import fields, models class ResCompany(models.Model): _inherit = "res.company" + holiday_overtime_factor = fields.Float( + default=1, + help="When activated on holidays/weekends, overtime is multiplied with this factor", + ) + def write(self, vals): """Don't delete overtime records that are adjustments when changing overtime settings""" if "hr_attendance_overtime" in vals or "overtime_start_date" in vals: diff --git a/verdigado_attendance/models/res_config_settings.py b/verdigado_attendance/models/res_config_settings.py new file mode 100644 index 0000000..f6ecd6d --- /dev/null +++ b/verdigado_attendance/models/res_config_settings.py @@ -0,0 +1,13 @@ +# Copyright 2023 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + holiday_overtime_factor = fields.Float( + related="company_id.holiday_overtime_factor", readonly=False + ) diff --git a/verdigado_attendance/models/res_users.py b/verdigado_attendance/models/res_users.py new file mode 100644 index 0000000..e0b5436 --- /dev/null +++ b/verdigado_attendance/models/res_users.py @@ -0,0 +1,11 @@ +# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html + +from odoo import api, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + @api.model + def get_effective_holiday_overtime_factor(self): + return self.env.user.employee_id._get_effective_holiday_overtime_factor() diff --git a/verdigado_attendance/static/src/js/hr_attendance.js b/verdigado_attendance/static/src/js/hr_attendance.js new file mode 100644 index 0000000..f0db8ed --- /dev/null +++ b/verdigado_attendance/static/src/js/hr_attendance.js @@ -0,0 +1,33 @@ +/* Copyright 2023 Hunki Enterprises BV + * License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ + +odoo.define("verdigado_attendance.hr_attendance", function (require) { + "use strict"; + + var myAttendances = require("hr_attendance.my_attendances"); + + myAttendances.include({ + willStart: function () { + var self = this; + var promise = this._rpc({ + model: "res.users", + method: "get_effective_holiday_overtime_factor", + }).then(function (data) { + self.effective_holiday_overtime_factor = data; + }); + return Promise.all([this._super.apply(this, arguments), promise]); + }, + _rpc: function (params) { + if ( + params && + params.model === "hr.employee" && + params.method === "attendance_manual" + ) { + params.context.default_apply_holiday_overtime_factor = this.$( + "#apply_holiday_overtime" + ).is(":checked"); + } + return this._super.apply(this, arguments); + }, + }); +}); diff --git a/verdigado_attendance/static/src/xml/hr_attendance.xml b/verdigado_attendance/static/src/xml/hr_attendance.xml new file mode 100644 index 0000000..712d501 --- /dev/null +++ b/verdigado_attendance/static/src/xml/hr_attendance.xml @@ -0,0 +1,16 @@ + + + + +
+ + +
+
+
+
diff --git a/verdigado_attendance/tests/test_overtime_calculation.py b/verdigado_attendance/tests/test_overtime_calculation.py index 65446ce..c896807 100644 --- a/verdigado_attendance/tests/test_overtime_calculation.py +++ b/verdigado_attendance/tests/test_overtime_calculation.py @@ -5,20 +5,17 @@ import pytz from odoo import fields -from odoo.tests.common import TransactionCase +from .hr_case import HrCase -class TestOvertimeCalculation(TransactionCase): + +class TestOvertimeCalculation(HrCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.employeeA = cls.env["hr.employee"].create( + cls.employeeA = cls.env.ref("verdigado_attendance.verdigado_user_employee") + cls.employeeA.write( { - "name": "employeeA", - # 40h/w - "resource_calendar_id": cls.env.ref( - "resource.resource_calendar_std" - ).id, "address_id": cls.env["res.partner"] .create( { @@ -30,18 +27,6 @@ def setUpClass(cls): .id, } ) - cls.employeeAallocation = cls.env["hr.leave.allocation"].create( - { - "employee_id": cls.employeeA.id, - "holiday_type": "employee", - "holiday_status_id": cls.env.ref("hr_holidays.holiday_status_cl").id, - "number_of_days": 30, - "date_from": "2023-01-01", - "date_to": "2023-12-31", - } - ) - cls.employeeAallocation.action_confirm() - cls.employeeAallocation.action_validate() cls.env["hr.holidays.public.generator"].create( { "year": 2023, @@ -143,6 +128,38 @@ def test_calculation_employeeA(self): self.assertOvertime(employeeA, "2023-08-06", 33 * 60, 0) self.assertOvertime(employeeA, "2023-08-07", 0, 8 * 60) + # set an overtime factor and see what happens when we set the flag + employeeA.company_id.write( + { + "holiday_overtime_factor": 1.5, + } + ) + employeeA.write( + { + "custom_holiday_overtime_factor": True, + "holiday_overtime_factor": 2.5, + } + ) + attendance.apply_holiday_overtime_factor = True + self.assertOvertime(employeeA, "2023-08-06", 33 * 60, 0) + self.assertOvertime(employeeA, "2023-08-06", 1.5 * 33 * 60, 0, adjustment=True) + extra_overtime = self.env["hr.attendance.overtime"].search( + [ + ("employee_id", "=", employeeA.id), + ("date", "=", "2023-08-06"), + ("adjustment", "=", True), + ] + ) + self.assertEqual( + extra_overtime.note, "Extra overtime from holiday factor (2.50)" + ) + attendance.check_out += timedelta(hours=1) + self.assertOvertime(employeeA, "2023-08-06", 34 * 60, 0) + self.assertOvertime(employeeA, "2023-08-06", 1.5 * 34 * 60, 0, adjustment=True) + attendance.unlink() + self.assertOvertime(employeeA, "2023-08-06", 0, 0) + self.assertOvertime(employeeA, "2023-08-06", 0, 0, adjustment=True) + def to_time(self, time_string): if isinstance(time_string, str): return datetime.strptime(time_string, "%H:%M:%S").time() @@ -163,37 +180,49 @@ def record_time(self, employee, date, checkin_time, checkout_time): checkout_time = self.to_time(checkout_time) tz = pytz.timezone(employee.tz) - return self.env["hr.attendance"].create( - { - "employee_id": employee.id, - "check_in": tz.localize(datetime.combine(date, checkin_time)) - .astimezone(pytz.utc) - .replace(tzinfo=None), - "check_out": tz.localize(datetime.combine(date, checkout_time)) - .astimezone(pytz.utc) - .replace(tzinfo=None), - } + return ( + self.env["hr.attendance"] + .with_user(employee.user_id) + .create( + { + "employee_id": employee.id, + "check_in": tz.localize(datetime.combine(date, checkin_time)) + .astimezone(pytz.utc) + .replace(tzinfo=None), + "check_out": tz.localize(datetime.combine(date, checkout_time)) + .astimezone(pytz.utc) + .replace(tzinfo=None), + } + ) ) def take_leave(self, employee, date_from, date_to): - leave = self.env["hr.leave"].create( - { - "employee_id": employee.id, - "date_from": self.local_date_to_utc_datetime(employee, date_from), - "date_to": self.local_date_to_utc_datetime(employee, date_to) - + timedelta(days=1), - "holiday_status_id": self.env.ref("hr_holidays.holiday_status_cl").id, - } + leave = ( + self.env["hr.leave"] + .with_user(employee.user_id) + .create( + { + "employee_id": employee.id, + "date_from": self.local_date_to_utc_datetime(employee, date_from), + "date_to": self.local_date_to_utc_datetime(employee, date_to) + + timedelta(days=1), + "holiday_status_id": self.env.ref( + "hr_holidays.holiday_status_cl" + ).id, + } + ) ) - leave.action_approve() - leave.action_validate() + leave.with_user(self.verdigado_manager).action_approve() + leave.with_user(self.verdigado_manager).action_validate() - def assertOvertime(self, employee, date, minutes, expected_minutes=None): + def assertOvertime( + self, employee, date, minutes, expected_minutes=None, adjustment=False + ): overtime = self.env["hr.attendance.overtime"].search( [ ("employee_id", "=", employee.id), ("date", "=", date), - ("adjustment", "=", False), + ("adjustment", "=", adjustment), ] ) self.assertEqual( diff --git a/verdigado_attendance/views/hr_attendance_view.xml b/verdigado_attendance/views/hr_attendance_view.xml index 6267f55..4855159 100644 --- a/verdigado_attendance/views/hr_attendance_view.xml +++ b/verdigado_attendance/views/hr_attendance_view.xml @@ -14,6 +14,9 @@ 1 bottom + + + diff --git a/verdigado_attendance/views/hr_employee.xml b/verdigado_attendance/views/hr_employee.xml new file mode 100644 index 0000000..88c0f0f --- /dev/null +++ b/verdigado_attendance/views/hr_employee.xml @@ -0,0 +1,16 @@ + + + + + hr.employee + + + + + + + + diff --git a/verdigado_attendance/views/res_config_settings.xml b/verdigado_attendance/views/res_config_settings.xml new file mode 100644 index 0000000..a2c9f9a --- /dev/null +++ b/verdigado_attendance/views/res_config_settings.xml @@ -0,0 +1,34 @@ + + + + + res.config.settings + + +
+

Overtime on holidays

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