diff --git a/resource_work_time_from_contracts/README.rst b/resource_work_time_from_contracts/README.rst new file mode 100644 index 000000000..1a0291593 --- /dev/null +++ b/resource_work_time_from_contracts/README.rst @@ -0,0 +1,85 @@ +================================= +Resource Work Time From Contracts +================================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-coopiteasy%2Faddons-lightgray.png?logo=github + :target: https://github.com/coopiteasy/addons/tree/12.0/resource_work_time_from_contracts + :alt: coopiteasy/addons + +|badge1| |badge2| |badge3| + +Take the contracts of an employee into account when computing work time per +day. + +When this module is installed, the number of hours an employee is supposed to +work is only computed from their contracts. Without contracts, the work time +per day is 0, instead of using the default company’s working hours. + +The start and end dates of contracts are taken into account, but the status +(state) of contracts are ignored. + +For this module to work properly, the company’s working hours should encompass +all possible work days (including weekend days if there are contracts with +weekend days), and each day should have working hours that correspond to the +working hours used in all contracts. This is because the company’s working +hours are used to compute leaves, and the number of hours per day is computed +from it. + +For example, if the company working hours define 8 hours per day, from 8 to 12 +and 13 to 17, all contracts’ working hours should be set from 8 to 12 and/or +from 13 to 17 for the corresponding days. Half days are thus supported. + +If there are contracts with working hours that don’t match the company’s +working hours, the number of days for leaves will be computed incorrectly. + +This module also makes the working hours (resource calendar) of an employee +always equal to the company’s working hours, and hides its field on the +employee form view. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Coop IT Easy SC + +Contributors +~~~~~~~~~~~~ + +* `Coop IT Easy SC `_: + + * hugues de keyzer + +Maintainers +~~~~~~~~~~~ + +This module is part of the `coopiteasy/addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/resource_work_time_from_contracts/__init__.py b/resource_work_time_from_contracts/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/resource_work_time_from_contracts/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/resource_work_time_from_contracts/__manifest__.py b/resource_work_time_from_contracts/__manifest__.py new file mode 100644 index 000000000..ac3088830 --- /dev/null +++ b/resource_work_time_from_contracts/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Resource Work Time From Contracts", + "summary": ( + "Take the contracts of an employee into account when computing work " + "time per day" + ), + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Coop IT Easy SC", + "website": "https://github.com/coopiteasy/addons", + "category": "Human Resources", + "depends": [ + "hr_contract", + ], + "data": [ + "views/hr_employee.xml", + "views/resource_resource.xml", + ], + "demo": [], +} diff --git a/resource_work_time_from_contracts/i18n/resource_work_time_from_contracts.pot b/resource_work_time_from_contracts/i18n/resource_work_time_from_contracts.pot new file mode 100644 index 000000000..7f37cedeb --- /dev/null +++ b/resource_work_time_from_contracts/i18n/resource_work_time_from_contracts.pot @@ -0,0 +1,54 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * resource_work_time_from_contracts +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 12.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: resource_work_time_from_contracts +#: model:ir.model.fields,help:resource_work_time_from_contracts.field_hr_employee__resource_calendar_id +#: model:ir.model.fields,help:resource_work_time_from_contracts.field_mrp_workcenter__resource_calendar_id +#: model:ir.model.fields,help:resource_work_time_from_contracts.field_resource_calendar_leaves__calendar_id +#: model:ir.model.fields,help:resource_work_time_from_contracts.field_resource_mixin__resource_calendar_id +#: model:ir.model.fields,help:resource_work_time_from_contracts.field_resource_resource__calendar_id +#: model:ir.model.fields,help:resource_work_time_from_contracts.field_resource_test__resource_calendar_id +msgid "Define the schedule of resource" +msgstr "" + +#. module: resource_work_time_from_contracts +#: model:ir.model,name:resource_work_time_from_contracts.model_resource_calendar_leaves +msgid "Resource Leaves Detail" +msgstr "" + +#. module: resource_work_time_from_contracts +#: model:ir.model,name:resource_work_time_from_contracts.model_resource_mixin +msgid "Resource Mixin" +msgstr "" + +#. module: resource_work_time_from_contracts +#: model:ir.model,name:resource_work_time_from_contracts.model_resource_resource +msgid "Resources" +msgstr "" + +#. module: resource_work_time_from_contracts +#: model:ir.model.fields,field_description:resource_work_time_from_contracts.field_hr_employee__resource_calendar_id +#: model:ir.model.fields,field_description:resource_work_time_from_contracts.field_mrp_workcenter__resource_calendar_id +#: model:ir.model.fields,field_description:resource_work_time_from_contracts.field_resource_calendar_leaves__calendar_id +#: model:ir.model.fields,field_description:resource_work_time_from_contracts.field_resource_mixin__resource_calendar_id +#: model:ir.model.fields,field_description:resource_work_time_from_contracts.field_resource_test__resource_calendar_id +msgid "Working Hours" +msgstr "" + +#. module: resource_work_time_from_contracts +#: model:ir.model.fields,field_description:resource_work_time_from_contracts.field_resource_resource__calendar_id +msgid "Working Time" +msgstr "" + diff --git a/resource_work_time_from_contracts/models/__init__.py b/resource_work_time_from_contracts/models/__init__.py new file mode 100644 index 000000000..3fb1ac327 --- /dev/null +++ b/resource_work_time_from_contracts/models/__init__.py @@ -0,0 +1,3 @@ +from . import resource_calendar_leaves +from . import resource_mixin +from . import resource_resource diff --git a/resource_work_time_from_contracts/models/resource_calendar_leaves.py b/resource_work_time_from_contracts/models/resource_calendar_leaves.py new file mode 100644 index 000000000..a2483761b --- /dev/null +++ b/resource_work_time_from_contracts/models/resource_calendar_leaves.py @@ -0,0 +1,21 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResourceCalendarLeaves(models.Model): + + _inherit = "resource.calendar.leaves" + + # force this field to be equal to the resource_calendar_id of the resource + # (which should be equal to the one of the company). this ensures that all + # leaves for all resources are defined in the same resource calendar, + # which is needed to compute working hours while taking leaves into + # account. + calendar_id = fields.Many2one( + "resource.calendar", + related="resource_id.calendar_id", + readonly=True, + store=True, + ) diff --git a/resource_work_time_from_contracts/models/resource_mixin.py b/resource_work_time_from_contracts/models/resource_mixin.py new file mode 100644 index 000000000..442869f61 --- /dev/null +++ b/resource_work_time_from_contracts/models/resource_mixin.py @@ -0,0 +1,235 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import datetime +from collections import defaultdict + +from pytz import timezone, utc + +from odoo import fields, models +from odoo.tools import float_utils + +from odoo.addons.resource.models.resource import ROUNDING_FACTOR + + +class ResourceMixin(models.AbstractModel): + + _inherit = "resource.mixin" + + # make this field read-only. + resource_calendar_id = fields.Many2one("resource.calendar", readonly=True) + + def list_work_time_per_day( + self, + from_datetime, + to_datetime, + calendar=None, + domain=None, + ): + if calendar or not hasattr(self, "contract_ids"): + return super().list_work_time_per_day( + from_datetime, to_datetime, calendar, domain + ) + work_time_from_contracts = self._get_work_time_from_contracts( + from_datetime, to_datetime, domain + ) + # we need to take leaves into account. instead of going into the + # internals of leaves themselves which are quite complex (and mostly + # private to the resource module), we ask for the default work time + # with and without leaves, and compute the difference, that we + # subtract from the work time from the contracts. + # + # to get the default work time without leaves, we provide a domain + # that will yield no results. the domain argument is used to query + # leave intervals from resource.calendar.leaves (in + # resource.resource.ResourceCalendar._leave_intervals()). the default + # is [('time_type', '=', 'leave')]. to ensure that no leaves are + # found, we want to use a domain that will never return anything. we + # use [("calendar_id", "=", False)] because it will be added to + # another domain asking for a specific calendar_id, resulting in a + # query returning no results. + # + # for this, we query the calendar of the employee, which must be the + # same as the one of the company, and which must contain full-day + # hours for each day. this is the calendar to which leaves are linked, + # and is used by default by + # resource.resource_mixin.list_work_time_per_day() when calendar=None. + default_work_time = dict( + super().list_work_time_per_day( + from_datetime, + to_datetime, + self.resource_calendar_id, + domain=[("calendar_id", "=", False)], + ) + ) + default_work_time_with_leaves = super().list_work_time_per_day( + from_datetime, to_datetime, self.resource_calendar_id, domain + ) + result = [] + for day, hours in default_work_time_with_leaves: + total_hours = work_time_from_contracts[day] - ( + default_work_time[day] - hours + ) + if total_hours <= 0.0: + continue + result.append((day, total_hours)) + return result + + def list_normal_work_time_per_day(self, from_datetime, to_datetime, domain=None): + """ + Same as list_work_time_per_day(), but ignoring leaves + """ + work_time_from_contracts = self._get_work_time_from_contracts( + from_datetime, to_datetime, domain + ) + from_datetime, to_datetime = self._localize_datetimes( + from_datetime, to_datetime + ) + day = from_datetime.date() + to_day = to_datetime.date() + delta = datetime.timedelta(days=1) + result = [] + while day <= to_day: + hours = work_time_from_contracts[day] + if hours != 0.0: + result.append((day, hours)) + day += delta + return result + + def _get_work_days_data_batch( + self, + from_datetime, + to_datetime, + compute_leaves=True, + calendar=None, + domain=None, + ): + if calendar or not hasattr(self, "contract_ids"): + return super()._get_work_days_data_batch( + from_datetime, to_datetime, compute_leaves, calendar, domain + ) + # we need the normal work time per day for each day to be able to + # compute the fraction of day that the number of hours represents. + # this is defined in the calendar of the employee, which must be the + # same as the one of the company, and which must contain full-day + # hours for each day. + # + # we need full days, so we replace the hours to start and stop at + # midnight in the timezone of the resource. + # + # the provided domain is to exclude leaves from the computation, as + # explained in list_work_time_per_day(). + from_datetime, to_datetime = self._localize_datetimes( + from_datetime, to_datetime + ) + normal_work_time_per_day = dict( + super().list_work_time_per_day( + from_datetime.replace(hour=0, minute=0, second=0, microsecond=0), + to_datetime.replace(hour=0, minute=0, second=0, microsecond=0) + + datetime.timedelta(days=1), + self.resource_calendar_id, + [("calendar_id", "=", False)], + ) + ) + if compute_leaves: + work_time_per_day = self.list_work_time_per_day( + from_datetime, to_datetime, calendar=None, domain=domain + ) + else: + work_time_per_day = self.list_normal_work_time_per_day( + from_datetime, to_datetime, domain=domain + ) + num_days = 0.0 + num_hours = 0.0 + for day, work_time in work_time_per_day: + if work_time == 0.0: + continue + normal_work_time = normal_work_time_per_day[day] + # we use the same rounding computation as in + # resource.resource_mixin._get_work_days_data_batch(). + num_days += ( + float_utils.round(ROUNDING_FACTOR * work_time / normal_work_time) + / ROUNDING_FACTOR + ) + num_hours += work_time + return {"days": num_days, "hours": num_hours} + + def _get_active_contracts(self, date_start, date_end): + """ + Get active contracts for the provided date range. + """ + return ( + self.env["hr.contract"] + .sudo() + .search( + [ + ("employee_id", "=", self.id), + ("date_start", "<=", date_end), + "|", + ("date_end", "=", None), + ("date_end", ">=", date_start), + ] + ) + ) + + def _get_work_time_per_contract( + self, contracts, from_datetime, to_datetime, domain + ): + """ + Return the work time per day per contract. + """ + work_time_results = [] + for contract in contracts: + from_dt = from_datetime + to_dt = to_datetime + date_start = contract.date_start + if from_dt.date() < date_start: + from_dt = datetime.datetime( + date_start.year, + date_start.month, + date_start.day, + tzinfo=from_dt.tzinfo, + ) + date_end = contract.date_end + if date_end and to_dt.date() > date_end: + # limit to midnight on the day after date_end to completely + # include date_end. + to_dt = datetime.datetime( + date_end.year, + date_end.month, + date_end.day, + tzinfo=from_dt.tzinfo, + ) + datetime.timedelta(days=1) + work_time_results.append( + super().list_work_time_per_day( + from_dt, + to_dt, + contract.resource_calendar_id, + domain, + ) + ) + return work_time_results + + def _get_work_time_from_contracts(self, from_datetime, to_datetime, domain=None): + from_datetime, to_datetime = self._localize_datetimes( + from_datetime, to_datetime + ) + contracts = self._get_active_contracts(from_datetime.date(), to_datetime.date()) + work_time_results = self._get_work_time_per_contract( + contracts, from_datetime, to_datetime, domain + ) + result = defaultdict(float) + for work_time in work_time_results: + for day, hours in work_time: + result[day] += hours + return result + + def _localize_datetimes(self, from_datetime, to_datetime): + # naive datetimes are considered utc + if not from_datetime.tzinfo: + from_datetime = from_datetime.replace(tzinfo=utc) + if not to_datetime.tzinfo: + to_datetime = to_datetime.replace(tzinfo=utc) + + tz = timezone(self.tz) + return from_datetime.astimezone(tz), to_datetime.astimezone(tz) diff --git a/resource_work_time_from_contracts/models/resource_resource.py b/resource_work_time_from_contracts/models/resource_resource.py new file mode 100644 index 000000000..134f6934a --- /dev/null +++ b/resource_work_time_from_contracts/models/resource_resource.py @@ -0,0 +1,17 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResourceResource(models.Model): + + _inherit = "resource.resource" + + # force this field to be equal to the resource_calendar_id of the company. + calendar_id = fields.Many2one( + "resource.calendar", + related="company_id.resource_calendar_id", + readonly=True, + store=True, + ) diff --git a/resource_work_time_from_contracts/readme/CONTRIBUTORS.rst b/resource_work_time_from_contracts/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..eb7c015bf --- /dev/null +++ b/resource_work_time_from_contracts/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Coop IT Easy SC `_: + + * hugues de keyzer diff --git a/resource_work_time_from_contracts/readme/DESCRIPTION.rst b/resource_work_time_from_contracts/readme/DESCRIPTION.rst new file mode 100644 index 000000000..68ac2ab2e --- /dev/null +++ b/resource_work_time_from_contracts/readme/DESCRIPTION.rst @@ -0,0 +1,27 @@ +Take the contracts of an employee into account when computing work time per +day. + +When this module is installed, the number of hours an employee is supposed to +work is only computed from their contracts. Without contracts, the work time +per day is 0, instead of using the default company’s working hours. + +The start and end dates of contracts are taken into account, but the status +(state) of contracts are ignored. + +For this module to work properly, the company’s working hours should encompass +all possible work days (including weekend days if there are contracts with +weekend days), and each day should have working hours that correspond to the +working hours used in all contracts. This is because the company’s working +hours are used to compute leaves, and the number of hours per day is computed +from it. + +For example, if the company working hours define 8 hours per day, from 8 to 12 +and 13 to 17, all contracts’ working hours should be set from 8 to 12 and/or +from 13 to 17 for the corresponding days. Half days are thus supported. + +If there are contracts with working hours that don’t match the company’s +working hours, the number of days for leaves will be computed incorrectly. + +This module also makes the working hours (resource calendar) of an employee +always equal to the company’s working hours, and hides its field on the +employee form view. diff --git a/resource_work_time_from_contracts/static/description/index.html b/resource_work_time_from_contracts/static/description/index.html new file mode 100644 index 000000000..2ee2acb88 --- /dev/null +++ b/resource_work_time_from_contracts/static/description/index.html @@ -0,0 +1,437 @@ + + + + + + +Resource Work Time From Contracts + + + +
+

Resource Work Time From Contracts

+ + +

Beta License: AGPL-3 coopiteasy/addons

+

Take the contracts of an employee into account when computing work time per +day.

+

When this module is installed, the number of hours an employee is supposed to +work is only computed from their contracts. Without contracts, the work time +per day is 0, instead of using the default company’s working hours.

+

The start and end dates of contracts are taken into account, but the status +(state) of contracts are ignored.

+

For this module to work properly, the company’s working hours should encompass +all possible work days (including weekend days if there are contracts with +weekend days), and each day should have working hours that correspond to the +working hours used in all contracts. This is because the company’s working +hours are used to compute leaves, and the number of hours per day is computed +from it.

+

For example, if the company working hours define 8 hours per day, from 8 to 12 +and 13 to 17, all contracts’ working hours should be set from 8 to 12 and/or +from 13 to 17 for the corresponding days. Half days are thus supported.

+

If there are contracts with working hours that don’t match the company’s +working hours, the number of days for leaves will be computed incorrectly.

+

This module also makes the working hours (resource calendar) of an employee +always equal to the company’s working hours, and hides its field on the +employee form view.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Coop IT Easy SC
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the coopiteasy/addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/resource_work_time_from_contracts/tests/__init__.py b/resource_work_time_from_contracts/tests/__init__.py new file mode 100644 index 000000000..0d3bad2e6 --- /dev/null +++ b/resource_work_time_from_contracts/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_work_days_data +from . import test_work_time diff --git a/resource_work_time_from_contracts/tests/test_work_days_data.py b/resource_work_time_from_contracts/tests/test_work_days_data.py new file mode 100644 index 000000000..ca737abef --- /dev/null +++ b/resource_work_time_from_contracts/tests/test_work_days_data.py @@ -0,0 +1,450 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta, timezone + +from .test_work_time_base import TestWorkTimeBase + + +class TestWorkDaysData(TestWorkTimeBase): + def test_no_contract(self): + """ + Work days for an employee without a contract should be 0 + """ + self.assertEqual( + self._get_employee_work_days(), + { + "days": 0.0, + "hours": 0.0, + }, + ) + + def test_single_contract(self): + """ + Single contract + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + } + ) + self.assertEqual( + self._get_employee_work_days(), + { + "days": 5.0, + "hours": 38.0, + }, + ) + + def test_single_contract_with_start_date(self): + """ + Single contract with a start date in range + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2021-10-27", + } + ) + self.assertEqual( + self._get_employee_work_days(), + { + "days": 3.0, + # this is 22.8, but writing 22.8 will cause a failure because + # of floating-point precision. + "hours": 7.6 * 3, + }, + ) + + def test_single_contract_with_end_date(self): + """ + Single contract with an end date + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + "date_end": "2021-10-26", + } + ) + self.assertEqual( + self._get_employee_work_days(), + { + "days": 2.0, + "hours": 15.2, + }, + ) + + def test_multiple_contracts(self): + """ + Multiple simultaneous contracts + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.morning_calendar.id, + "date_start": "2020-10-24", + } + ) + self.env["hr.contract"].create( + { + "name": "Contract 2", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.afternoon_calendar.id, + "date_start": "2020-10-24", + } + ) + self.assertEqual( + self._get_employee_work_days(), + { + "days": 5.0, + "hours": 38.0, + }, + ) + + def test_multiple_contracts_with_dates(self): + """ + Multiple overlapping contracts with dates + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.morning_calendar.id, + "date_start": "2020-10-24", + "date_end": "2021-10-25", + } + ) + self.env["hr.contract"].create( + { + "name": "Contract 2", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.four_fifths_calendar.id, + "date_start": "2020-10-24", + } + ) + self.assertEqual( + self._get_employee_work_days(), + { + "days": 4.5, + "hours": 34.2, + }, + ) + + def test_with_leaves(self): + """ + Existing leaves should by default be subtracted from the work time + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + } + ) + + self.env["resource.calendar.leaves"].create( + { + "name": "Tuesday morning", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 26, 8, 42), + "date_to": self.to_utc_datetime(2021, 10, 26, 12, 30), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + self.env["resource.calendar.leaves"].create( + { + "name": "Wednesday afternoon", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 27, 13, 30), + "date_to": self.to_utc_datetime(2021, 10, 27, 17, 18), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + self.env["resource.calendar.leaves"].create( + { + "name": "Friday", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 29, 8, 42), + "date_to": self.to_utc_datetime(2021, 10, 29, 17, 18), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + self.assertEqual( + self._get_employee_work_days(), + { + "days": 3.0, + # this is 22.8, but writing 22.8 will cause a failure because + # of floating-point precision. + "hours": 7.6 * 3, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 25), + self.local_datetime(2021, 11, 1), + compute_leaves=False, + ), + { + "days": 5.0, + "hours": 38.0, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 12, 30), + ), + { + "days": 0.0, + "hours": 0.0, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 12, 30), + compute_leaves=False, + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 13, 30), + self.local_datetime(2021, 10, 26, 17, 18), + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 13, 30), + self.local_datetime(2021, 10, 26, 17, 18), + compute_leaves=False, + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 27, 8, 42), + self.local_datetime(2021, 10, 27, 17, 18), + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 27, 8, 42), + self.local_datetime(2021, 10, 27, 17, 18), + compute_leaves=False, + ), + { + "days": 1.0, + "hours": 7.6, + }, + ) + + def test_precision(self): + """ + Days should be rounded to the 1/16th. + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + } + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 8, 48), + ), + { + "days": 0.0, + "hours": 0.1, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 9, 6), + ), + { + "days": 0.0625, + "hours": 0.4, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 9, 18), + ), + { + "days": 0.0625, + "hours": 0.6, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 9, 36), + ), + { + "days": 0.125, + "hours": 0.9, + }, + ) + + def test_timezone(self): + """ + It should take the timezone into account. + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + } + ) + self.env["resource.calendar.leaves"].create( + { + "name": "Leave", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 26, 8, 42), + "date_to": self.to_utc_datetime(2021, 10, 26, 9, 30), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 12, 30), + ), + { + "days": 0.375, + "hours": 3.0, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.local_datetime(2021, 10, 26, 8, 42), + self.local_datetime(2021, 10, 26, 12, 30), + compute_leaves=False, + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.to_utc_datetime(2021, 10, 26, 8, 42), + self.to_utc_datetime(2021, 10, 26, 12, 30), + ), + { + "days": 0.375, + "hours": 3.0, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.to_utc_datetime(2021, 10, 26, 8, 42), + self.to_utc_datetime(2021, 10, 26, 12, 30), + compute_leaves=False, + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.to_utc_datetime(2021, 10, 26, 8, 42).replace(tzinfo=None), + self.to_utc_datetime(2021, 10, 26, 12, 30).replace(tzinfo=None), + ), + { + "days": 0.375, + "hours": 3.0, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.to_utc_datetime(2021, 10, 26, 8, 42).replace(tzinfo=None), + self.to_utc_datetime(2021, 10, 26, 12, 30).replace(tzinfo=None), + compute_leaves=False, + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.to_utc_datetime(2021, 10, 26, 8, 42).astimezone( + timezone(timedelta(hours=23)) + ), + self.to_utc_datetime(2021, 10, 26, 12, 30).astimezone( + timezone(timedelta(hours=-23)) + ), + ), + { + "days": 0.375, + "hours": 3.0, + }, + ) + self.assertEqual( + self.employee1._get_work_days_data_batch( + self.to_utc_datetime(2021, 10, 26, 8, 42).astimezone( + timezone(timedelta(hours=23)) + ), + self.to_utc_datetime(2021, 10, 26, 12, 30).astimezone( + timezone(timedelta(hours=-23)) + ), + compute_leaves=False, + ), + { + "days": 0.5, + "hours": 3.8, + }, + ) + + def _get_employee_work_days(self): + from_datetime = self.local_datetime(2021, 10, 25) + to_datetime = from_datetime + timedelta(days=7) + return self.employee1._get_work_days_data_batch(from_datetime, to_datetime) diff --git a/resource_work_time_from_contracts/tests/test_work_time.py b/resource_work_time_from_contracts/tests/test_work_time.py new file mode 100644 index 000000000..ff78cd8ec --- /dev/null +++ b/resource_work_time_from_contracts/tests/test_work_time.py @@ -0,0 +1,341 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import date, timedelta, timezone + +from .test_work_time_base import TestWorkTimeBase + + +class TestWorkTime(TestWorkTimeBase): + def test_no_contract(self): + """ + Work time for an employee without a contract should be 0 + """ + self.assertEqual(self._get_employee_work_time(), []) + + def test_single_contract(self): + """ + Single contract + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-18", + } + ) + self.assertEqual( + self._get_employee_work_time(), + [ + (date(2021, 10, 19), 7.6), + (date(2021, 10, 20), 7.6), + ], + ) + + def test_single_contract_with_start_date(self): + """ + Single contract with a start date in range + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2021-10-20", + } + ) + self.assertEqual( + self._get_employee_work_time(), + [(date(2021, 10, 20), 7.6)], + ) + + def test_single_contract_with_end_date(self): + """ + Single contract with an end date + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-18", + "date_end": "2021-10-19", + } + ) + self.assertEqual( + self._get_employee_work_time(), + [(date(2021, 10, 19), 7.6)], + ) + + def test_multiple_contracts(self): + """ + Multiple simultaneous contracts + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.morning_calendar.id, + "date_start": "2020-10-18", + } + ) + self.env["hr.contract"].create( + { + "name": "Contract 2", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.afternoon_calendar.id, + "date_start": "2020-10-18", + } + ) + self.assertEqual( + self._get_employee_work_time(), + [ + (date(2021, 10, 19), 7.6), + (date(2021, 10, 20), 7.6), + ], + ) + + def test_multiple_contracts_with_dates(self): + """ + Multiple overlapping contracts with dates + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.morning_calendar.id, + "date_start": "2020-10-18", + "date_end": "2021-10-19", + } + ) + self.env["hr.contract"].create( + { + "name": "Contract 2", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.four_fifths_calendar.id, + "date_start": "2020-10-19", + } + ) + self.assertEqual( + self._get_employee_work_time(), + [ + (date(2021, 10, 19), 3.8), + (date(2021, 10, 20), 7.6), + ], + ) + + def test_with_leaves(self): + """ + Existing leaves should by default be subtracted from the work time + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + } + ) + + self.env["resource.calendar.leaves"].create( + { + "name": "Tuesday morning", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 19, 8, 42), + "date_to": self.to_utc_datetime(2021, 10, 19, 12, 30), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + self.env["resource.calendar.leaves"].create( + { + "name": "Wednesday", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 20, 8, 42), + "date_to": self.to_utc_datetime(2021, 10, 20, 17, 18), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + # Across Tuesday and Wednesday, only work half a day. + self.assertEqual( + self._get_employee_work_time(), + [ + (date(2021, 10, 19), 3.8), + ], + ) + # On Tuesday morning, don't work. + self.assertEqual( + self.employee1.list_work_time_per_day( + self.local_datetime(2021, 10, 19, 8, 42), + self.local_datetime(2021, 10, 19, 12, 30), + ), + [], + ) + # On Tuesday morning, work when ignoring leaves. + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.local_datetime(2021, 10, 19, 8, 42), + self.local_datetime(2021, 10, 19, 12, 30), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + # On Tuesday afternoon, work. + self.assertEqual( + self.employee1.list_work_time_per_day( + self.local_datetime(2021, 10, 19, 13, 30), + self.local_datetime(2021, 10, 19, 17, 18), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.local_datetime(2021, 10, 19, 13, 30), + self.local_datetime(2021, 10, 19, 17, 18), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + # On all of Tuesday, work half a day. + self.assertEqual( + self.employee1.list_work_time_per_day( + self.local_datetime(2021, 10, 19, 8, 42), + self.local_datetime(2021, 10, 19, 17, 18), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + # On all of Tuesday, work the entire day ignoring leaves. + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.local_datetime(2021, 10, 19, 8, 42), + self.local_datetime(2021, 10, 19, 17, 18), + ), + [ + (date(2021, 10, 19), 7.6), + ], + ) + + def test_timezone(self): + """ + It should take the timezone into account. + """ + self.env["hr.contract"].create( + { + "name": "Contract 1", + "employee_id": self.employee1.id, + "wage": 0.0, + "resource_calendar_id": self.full_time_calendar.id, + "date_start": "2020-10-24", + } + ) + self.env["resource.calendar.leaves"].create( + { + "name": "Leave", + "calendar_id": self.employee1.resource_calendar_id.id, + "date_from": self.to_utc_datetime(2021, 10, 19, 8, 42), + "date_to": self.to_utc_datetime(2021, 10, 19, 9, 30), + "resource_id": self.employee1.resource_id.id, + "time_type": "leave", + } + ) + self.assertEqual( + self.employee1.list_work_time_per_day( + self.local_datetime(2021, 10, 19, 8, 42), + self.local_datetime(2021, 10, 19, 12, 30), + ), + [ + (date(2021, 10, 19), 3.0), + ], + ) + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.local_datetime(2021, 10, 19, 8, 42), + self.local_datetime(2021, 10, 19, 12, 30), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + self.assertEqual( + self.employee1.list_work_time_per_day( + self.to_utc_datetime(2021, 10, 19, 8, 42), + self.to_utc_datetime(2021, 10, 19, 12, 30), + ), + [ + (date(2021, 10, 19), 3.0), + ], + ) + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.to_utc_datetime(2021, 10, 19, 8, 42), + self.to_utc_datetime(2021, 10, 19, 12, 30), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + self.assertEqual( + self.employee1.list_work_time_per_day( + self.to_utc_datetime(2021, 10, 19, 8, 42).replace(tzinfo=None), + self.to_utc_datetime(2021, 10, 19, 12, 30).replace(tzinfo=None), + ), + [ + (date(2021, 10, 19), 3.0), + ], + ) + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.to_utc_datetime(2021, 10, 19, 8, 42).replace(tzinfo=None), + self.to_utc_datetime(2021, 10, 19, 12, 30).replace(tzinfo=None), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + self.assertEqual( + self.employee1.list_work_time_per_day( + self.to_utc_datetime(2021, 10, 19, 8, 42).astimezone( + timezone(timedelta(hours=23)) + ), + self.to_utc_datetime(2021, 10, 19, 12, 30).astimezone( + timezone(timedelta(hours=-23)) + ), + ), + [ + (date(2021, 10, 19), 3.0), + ], + ) + self.assertEqual( + self.employee1.list_normal_work_time_per_day( + self.to_utc_datetime(2021, 10, 19, 8, 42).astimezone( + timezone(timedelta(hours=23)) + ), + self.to_utc_datetime(2021, 10, 19, 12, 30).astimezone( + timezone(timedelta(hours=-23)) + ), + ), + [ + (date(2021, 10, 19), 3.8), + ], + ) + + def _get_employee_work_time(self): + from_datetime = self.local_datetime(2021, 10, 19) + to_datetime = from_datetime + timedelta(days=2) + return self.employee1.list_work_time_per_day(from_datetime, to_datetime) diff --git a/resource_work_time_from_contracts/tests/test_work_time_base.py b/resource_work_time_from_contracts/tests/test_work_time_base.py new file mode 100644 index 000000000..acaba1f2c --- /dev/null +++ b/resource_work_time_from_contracts/tests/test_work_time_base.py @@ -0,0 +1,152 @@ +# Copyright 2021 Coop IT Easy SC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import datetime + +import pytz + +from odoo.tests.common import TransactionCase + + +class TestWorkTimeBase(TransactionCase): + def setUp(self): + super().setUp() + + self.timezone = pytz.timezone(self.env.user.tz) + + # users + user1_dict = {"name": "User 1", "login": "user1", "password": "user1"} + self.user1 = self.env["res.users"].create(user1_dict) + + # employees + employee1_dict = { + "name": "Employee 1", + "user_id": self.user1.id, + "address_id": self.user1.partner_id.id, + } + self.employee1 = self.env["hr.employee"].create(employee1_dict) + + # working hours + # calendar have default attendance_ids, force it to have none. + self.full_time_calendar = self.env["resource.calendar"].create( + {"name": "Full-time", "attendance_ids": False} + ) + for day in range(5): + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 8.7, # 8:42 + "hour_to": 12.5, # 12:30 + "calendar_id": self.full_time_calendar.id, + } + ) + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 13.5, # 13:30 + "hour_to": 17.3, # 17:18 + "calendar_id": self.full_time_calendar.id, + } + ) + + self.morning_calendar = self.env["resource.calendar"].create( + {"name": "Morning", "attendance_ids": False} + ) + for day in range(5): + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 8.7, # 8:42 + "hour_to": 12.5, # 12:30 + "calendar_id": self.morning_calendar.id, + } + ) + + self.afternoon_calendar = self.env["resource.calendar"].create( + {"name": "Afternoon", "attendance_ids": False} + ) + for day in range(5): + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 13.5, # 13:30 + "hour_to": 17.3, # 17:18 + "calendar_id": self.afternoon_calendar.id, + } + ) + + self.four_fifths_calendar = self.env["resource.calendar"].create( + {"name": "Four fifth", "attendance_ids": False} + ) + for day in (0, 2, 3, 4): + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 8.7, # 8:42 + "hour_to": 12.5, # 12:30 + "calendar_id": self.four_fifths_calendar.id, + } + ) + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 13.5, # 13:30 + "hour_to": 17.3, # 17:18 + "calendar_id": self.four_fifths_calendar.id, + } + ) + + self.company_calendar = self.env["resource.calendar"].create( + {"name": "Company", "attendance_ids": False} + ) + # the company calendar must contain full-time days + # we use a non-default calendar to ensure it works. + for day in range(7): + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 8.7, # 8:42 + "hour_to": 12.5, # 12:30 + "calendar_id": self.company_calendar.id, + } + ) + self.env["resource.calendar.attendance"].create( + { + "name": "Attendance", + "dayofweek": str(day), + "hour_from": 13.5, # 13:30 + "hour_to": 17.3, # 17:18 + "calendar_id": self.company_calendar.id, + } + ) + self.employee1.company_id.resource_calendar_id = self.company_calendar + + def local_datetime(self, year, month, day, *args, **kwargs): + """ + Create a datetime with the local timezone from local time values + """ + return self.timezone.localize( + datetime.datetime(year, month, day, *args, **kwargs) + ) + + def to_utc_datetime(self, year, month, day, *args, **kwargs): + """ + Create a UTC datetime from local time values + """ + return ( + self.timezone.localize( + datetime.datetime(year, month, day, *args, **kwargs) + ).astimezone(pytz.utc) + # Odoo's ORM refuses to work with datetime objects that have tzinfo + # set. Unset it here instead of doing it manually every time. The + # tzinfo is implicit as a result of this function's name being + # 'to_utc_datetime'. + .replace(tzinfo=None) + ) diff --git a/resource_work_time_from_contracts/views/hr_employee.xml b/resource_work_time_from_contracts/views/hr_employee.xml new file mode 100644 index 000000000..0d448a58d --- /dev/null +++ b/resource_work_time_from_contracts/views/hr_employee.xml @@ -0,0 +1,17 @@ + + + + + hr.employee.form (in resource_work_time_from_contracts) + hr.employee + + + + True + + + + diff --git a/resource_work_time_from_contracts/views/resource_resource.xml b/resource_work_time_from_contracts/views/resource_resource.xml new file mode 100644 index 000000000..ee7b9d0ae --- /dev/null +++ b/resource_work_time_from_contracts/views/resource_resource.xml @@ -0,0 +1,17 @@ + + + + + resource.resource.tree (in resource_work_time_from_contracts) + resource.resource + + + + True + + + + diff --git a/setup/resource_work_time_from_contracts/odoo/addons/resource_work_time_from_contracts b/setup/resource_work_time_from_contracts/odoo/addons/resource_work_time_from_contracts new file mode 120000 index 000000000..fd9220d86 --- /dev/null +++ b/setup/resource_work_time_from_contracts/odoo/addons/resource_work_time_from_contracts @@ -0,0 +1 @@ +../../../../resource_work_time_from_contracts \ No newline at end of file diff --git a/setup/resource_work_time_from_contracts/setup.py b/setup/resource_work_time_from_contracts/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/resource_work_time_from_contracts/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)