From 907271c5c25537948f06c88e52a5cf5e41b47b53 Mon Sep 17 00:00:00 2001 From: Stephane Mangin Date: Tue, 10 Dec 2024 01:01:06 +0100 Subject: [PATCH] [15.0][ADD] hr_timesheet_overview --- hr_timesheet_overview/README.rst | 136 +++++++ hr_timesheet_overview/__init__.py | 4 + hr_timesheet_overview/__manifest__.py | 26 ++ hr_timesheet_overview/helpers.py | 72 ++++ hr_timesheet_overview/i18n/fr.po | 351 ++++++++++++++++++ .../migrations/15.0.1.1.0/pre-migration.py | 30 ++ .../migrations/15.0.1.2.0/pre-migration.py | 29 ++ .../migrations/15.0.1.3.0/pre-migration.py | 15 + hr_timesheet_overview/models/__init__.py | 5 + hr_timesheet_overview/models/hr_contract.py | 51 +++ .../models/hr_employee_hour.py | 148 ++++++++ .../models/hr_employee_hour_mixin.py | 75 ++++ hr_timesheet_overview/models/hr_timesheet.py | 68 ++++ .../models/resource_calendar.py | 37 ++ hr_timesheet_overview/readme/CONTRIBUTORS.md | 2 + hr_timesheet_overview/readme/DESCRIPTION.md | 42 +++ hr_timesheet_overview/readme/USAGE.md | 10 + hr_timesheet_overview/report/__init__.py | 1 + .../report/hr_employee_hour_report.py | 201 ++++++++++ .../report/hr_employee_hour_report_views.xml | 143 +++++++ .../security/ir.model.access.csv | 4 + hr_timesheet_overview/tests/__init__.py | 5 + hr_timesheet_overview/tests/common.py | 191 ++++++++++ hr_timesheet_overview/tests/test_helpers.py | 35 ++ .../tests/test_hr_contract.py | 58 +++ .../tests/test_hr_employee_hour.py | 214 +++++++++++ .../tests/test_hr_timesheet.py | 41 ++ .../views/hr_employee_hour_views.xml | 86 +++++ hr_timesheet_overview/views/menu_views.xml | 11 + hr_timesheet_overview/wizards/__init__.py | 1 + .../wizards/hr_employee_hour_updater.py | 88 +++++ .../wizards/hr_employee_hour_updater_view.xml | 54 +++ .../odoo/addons/hr_timesheet_overview | 1 + setup/hr_timesheet_overview/setup.py | 6 + 34 files changed, 2241 insertions(+) create mode 100644 hr_timesheet_overview/README.rst create mode 100644 hr_timesheet_overview/__init__.py create mode 100644 hr_timesheet_overview/__manifest__.py create mode 100644 hr_timesheet_overview/helpers.py create mode 100644 hr_timesheet_overview/i18n/fr.po create mode 100644 hr_timesheet_overview/migrations/15.0.1.1.0/pre-migration.py create mode 100644 hr_timesheet_overview/migrations/15.0.1.2.0/pre-migration.py create mode 100644 hr_timesheet_overview/migrations/15.0.1.3.0/pre-migration.py create mode 100644 hr_timesheet_overview/models/__init__.py create mode 100644 hr_timesheet_overview/models/hr_contract.py create mode 100644 hr_timesheet_overview/models/hr_employee_hour.py create mode 100644 hr_timesheet_overview/models/hr_employee_hour_mixin.py create mode 100644 hr_timesheet_overview/models/hr_timesheet.py create mode 100644 hr_timesheet_overview/models/resource_calendar.py create mode 100644 hr_timesheet_overview/readme/CONTRIBUTORS.md create mode 100644 hr_timesheet_overview/readme/DESCRIPTION.md create mode 100644 hr_timesheet_overview/readme/USAGE.md create mode 100644 hr_timesheet_overview/report/__init__.py create mode 100644 hr_timesheet_overview/report/hr_employee_hour_report.py create mode 100644 hr_timesheet_overview/report/hr_employee_hour_report_views.xml create mode 100644 hr_timesheet_overview/security/ir.model.access.csv create mode 100644 hr_timesheet_overview/tests/__init__.py create mode 100644 hr_timesheet_overview/tests/common.py create mode 100644 hr_timesheet_overview/tests/test_helpers.py create mode 100644 hr_timesheet_overview/tests/test_hr_contract.py create mode 100644 hr_timesheet_overview/tests/test_hr_employee_hour.py create mode 100644 hr_timesheet_overview/tests/test_hr_timesheet.py create mode 100644 hr_timesheet_overview/views/hr_employee_hour_views.xml create mode 100644 hr_timesheet_overview/views/menu_views.xml create mode 100644 hr_timesheet_overview/wizards/__init__.py create mode 100644 hr_timesheet_overview/wizards/hr_employee_hour_updater.py create mode 100644 hr_timesheet_overview/wizards/hr_employee_hour_updater_view.xml create mode 120000 setup/hr_timesheet_overview/odoo/addons/hr_timesheet_overview create mode 100644 setup/hr_timesheet_overview/setup.py diff --git a/hr_timesheet_overview/README.rst b/hr_timesheet_overview/README.rst new file mode 100644 index 0000000000..b379813a95 --- /dev/null +++ b/hr_timesheet_overview/README.rst @@ -0,0 +1,136 @@ +===================== +HR Timesheet Overview +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:dec11c63503653a4761c802a92d29169e6df3325b27e9f137da1fbfeba4433ac + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-OCA%2Ftimesheet-lightgray.png?logo=github + :target: https://github.com/OCA/timesheet/tree/15.0/hr_timesheet_overview + :alt: OCA/timesheet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/timesheet-15-0/timesheet-15-0-hr_timesheet_overview + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a dashboard to manage timesheet and contractual hours. + +General overview +================ + +The main purpose of this dashboard is to allow employee and manager to have an overview of their working time according to their contract. + +Two dashboard will be needed to have to axes of analyse and will cover the timesheet time and the contractual time of the employee + +Detailed requirements +===================== + +Dashboard Hours report +---------------------- + +The hours report will allow to calculate the under or over hours of the employee at a day level. For this purpose we need to calculate the contractual hours the employee is requested to do for each day. The sum of the contractual hours, leaves and timesheet will give the time variation according to the contract. + +The contractual hours should be calculated taking in account the following requirements: + +- The calculation per day should take in account the contract valid at the date (for the future if there is no end date for the contract we use the current one) +- The working time per day will use the work plan (resource calendar) on the employee contract with the data per day and if not available the average per day (only the working days) +- Bank holiday should be excluded and they can come from the the calendar or the OCA module Public holiday (if installed) +- Number will be show in negative (in order that the final sum is negative if there is insufficient timesheet according to the contract time +- The timesheet section have the following requirement + + - The timesheet time will be aggregated at project level (and not task as on the timesheet app) + - Working time and time off should be clearly separated (two columns) + - Time off are taken only if fully validated and from the leave object and timesheet created from a leave should be ignored (in order to avoid duplicates) + - By default we filter the current year until today and the user data. + +Global requirement +------------------ +The data coming from the timesheet and time off should always represent the situation we get if we go to the timesheet app or time off app (I mean by here that the data should be in real time). We accept that the data linked to the contract are updated every 24hours. + +At the initialisation the system should be able to generate the past data. + +Security +-------- + +The employee should not see the data from the others employee. +One exception for a manager that can see all the data from employees he is the manager of. + +Pitfalls +======== + +- Limit cases about hours on weekend and hours worked at night inbetween 2 days. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To generate employee hours lines at first install: + + # Go to *HR Report Dashboard* > *Update HR Employee hours* + +Then select the employees and the period you want to generate the lines for. +You also select only certain hour types: + +- Contractual hours +- Timesheet hours + + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp SA + +Contributors +~~~~~~~~~~~~ + +* Stéphane Mangin +* Florent Xicluna + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/timesheet `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_timesheet_overview/__init__.py b/hr_timesheet_overview/__init__.py new file mode 100644 index 0000000000..c7aa29af3a --- /dev/null +++ b/hr_timesheet_overview/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import report +from . import wizards +from . import helpers diff --git a/hr_timesheet_overview/__manifest__.py b/hr_timesheet_overview/__manifest__.py new file mode 100644 index 0000000000..2e62a71a0c --- /dev/null +++ b/hr_timesheet_overview/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "HR Timesheet Overview", + "version": "15.0.1.3.0", + "license": "AGPL-3", + "category": "Human Resources", + "website": "https://github.com/OCA/timesheet", + "author": "Camptocamp SA, Odoo Community Association (OCA)", + "depends": [ + "board", + "hr", + "hr_contract", + "hr_timesheet", + ], + "data": [ + "security/ir.model.access.csv", + "views/menu_views.xml", + "views/hr_employee_hour_views.xml", + "wizards/hr_employee_hour_updater_view.xml", + "report/hr_employee_hour_report_views.xml", + ], + "installable": True, + "auto_install": False, +} diff --git a/hr_timesheet_overview/helpers.py b/hr_timesheet_overview/helpers.py new file mode 100644 index 0000000000..4c487d5e1b --- /dev/null +++ b/hr_timesheet_overview/helpers.py @@ -0,0 +1,72 @@ +# Copyright 2023 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime, timedelta + +from odoo import fields + +from odoo.addons.resource.models.resource import HOURS_PER_DAY + +DEFAULT_TIME_QTY = {"hours_qty": HOURS_PER_DAY, "days_qty": 1.0, "type": "default"} + + +def get_end_of_day(date, attendances): + """Returns the datetime corresponding to the end of the day""" + hour_to = max(attendances.mapped("hour_to"), default=0.0) + assert 0 <= hour_to < 24 + time_part = datetime.utcfromtimestamp(hour_to * 60 * 60).time() + return datetime.combine(date, time_part) + + +def generate_dates_from_range(start_date, end_date=None): + """Prepare a list of dates to be processed""" + if not end_date: + end_date = fields.Date.today() + deltas = range((end_date - start_date).days + 1) + return [start_date + timedelta(days=dt) for dt in deltas] + + +def get_attendances_values_by_date(employees, date_from=None, date_to=None): + """Retrieve attendances (from resource module) values mapped by date + + These attendances are the matrix product of contractual hours by day + and week days (NOT the `hr.attendance` model). + + Warning: it is intended to be injected in context to reduce calls + + :param employees: a list of employee records + :param date_from: a datetime.date object (default first contract date, included) + :param date_to: a datetime.date object (default today, included) + """ + values_by_date = {} + for employee in employees: + # Always set a value even empty to avoid mistreatment later on. + values_by_date[employee.id] = {} + contracts = employee.contract_ids + if not contracts: + continue + for contract in contracts: + custom_date_from = contract.date_start + custom_date_to = contract.date_end + if date_from: + custom_date_from = max(date_from, custom_date_from) + if date_to: + if custom_date_to: + custom_date_to = min(date_to, custom_date_to) + else: + custom_date_to = date_to + if custom_date_to and custom_date_to < custom_date_from: + continue + # Manage dates outside contract validity + if contract.date_end and custom_date_to > contract.date_end: + continue + if custom_date_from < contract.date_start: + continue + + values = contract.prepare_hr_employee_hour_values( + date_start=custom_date_from, date_end=custom_date_to + ) + values_by_date[employee.id].update( + [(hvals["date"], hvals) for hvals in values] + ) + return values_by_date diff --git a/hr_timesheet_overview/i18n/fr.po b/hr_timesheet_overview/i18n/fr.po new file mode 100644 index 0000000000..351b75be62 --- /dev/null +++ b/hr_timesheet_overview/i18n/fr.po @@ -0,0 +1,351 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * hr_timesheet_overview +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-24 13:36+0000\n" +"PO-Revision-Date: 2023-01-24 14:43+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" +"X-Generator: Poedit 3.2.2\n" + +#. module: hr_timesheet_overview +#: model:ir.model,name:hr_timesheet_overview.model_hr_employee_hour_report_abstract +msgid "Abstract Employee Hour Report" +msgstr "" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__active +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__active +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__active +msgid "Active" +msgstr "" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__analytic_group_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__analytic_group_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__analytic_group_id +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_search_view +msgid "Analytic Group" +msgstr "Groupe analytique" + +#. module: hr_timesheet_overview +#: model:ir.model,name:hr_timesheet_overview.model_account_analytic_line +msgid "Analytic Line" +msgstr "Ligne analytique" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__company_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__company_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__company_id +msgid "Company" +msgstr "Société" + +#. module: hr_timesheet_overview +#: code:addons/hr_timesheet_overview/models/hr_employee_hour.py:0 +#: model:ir.model,name:hr_timesheet_overview.model_hr_contract +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour__type__contract +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour_report__type__contract +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour_report_abstract__type__contract +#, python-format +msgid "Contract" +msgstr "Contrat" + +#. module: hr_timesheet_overview +#: model:ir.actions.act_window,name:hr_timesheet_overview.action_hr_employee_hour +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_search_view +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_tree_view +msgid "Contract Hours" +msgstr "Heures contrat" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__date +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__date +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__date +msgid "Date" +msgstr "" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__days_qty +msgid "Day Qty" +msgstr "Qté en jour" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__days_qty +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__days_qty +msgid "Days" +msgstr "Jours" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__days_qty_abs +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__days_qty_abs +msgid "Days (abs)" +msgstr "Jours (Absolus)" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_account_analytic_line__display_name +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_contract__display_name +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__display_name +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__display_name +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__display_name +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_resource_calendar__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__employee_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__employee_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__employee_id +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_search_view +msgid "Employee" +msgstr "Employé" + +#. module: hr_timesheet_overview +#: model:ir.model,name:hr_timesheet_overview.model_hr_employee_hour_report +msgid "Employee Hour Report" +msgstr "Rapport des heures de l'employé" + +#. module: hr_timesheet_overview +#: model:ir.ui.menu,name:hr_timesheet_overview.menu_hr_employee_hour +msgid "Employee Hours" +msgstr "Heures de l'employé" + +#. module: hr_timesheet_overview +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_search_view +msgid "Group By" +msgstr "Grouper par" + +#. module: hr_timesheet_overview +#: model:ir.actions.server,name:hr_timesheet_overview.ir_cron_timesheet_hours_overview_force_ir_actions_server +#: model:ir.cron,cron_name:hr_timesheet_overview.ir_cron_timesheet_hours_overview_force +#: model:ir.cron,name:hr_timesheet_overview.ir_cron_timesheet_hours_overview_force +msgid "HR Employee Hours Report: Import yesterday" +msgstr "HR Rapport d'heures des employés : Import hier" + +#. module: hr_timesheet_overview +#: model:ir.cron,cron_name:hr_timesheet_overview.ir_cron_timesheet_hours_overview_update +#: model:ir.cron,name:hr_timesheet_overview.ir_cron_timesheet_hours_overview_update +msgid "HR Employee Hours Report: Reset Data (MANUALLY ONLY)" +msgstr "Rapport d'heures des employés : mise à jour" + +#. module: hr_timesheet_overview +#: model:ir.actions.server,name:hr_timesheet_overview.ir_cron_timesheet_hours_overview_update_ir_actions_server +msgid "HR Employee Hours Report: Update Data" +msgstr "Rapport d'heures des employés : mise à jour" + +#. module: hr_timesheet_overview +#: model:ir.model,name:hr_timesheet_overview.model_hr_employee_hour +msgid "HR Employee Hours per day" +msgstr "Rapport d'heures des employés par jours" + +#. module: hr_timesheet_overview +#: model:ir.ui.menu,name:hr_timesheet_overview.menu_hr_report_dashboard +msgid "HR dashboard" +msgstr "Dashboard RH" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__hours_qty +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__hours_qty +msgid "Hours" +msgstr "Heures" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__hours_qty_abs +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__hours_qty_abs +msgid "Hours (abs)" +msgstr "Heures (Absolues)" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__hours_qty +msgid "Hours Qty" +msgstr "Qté en heures" + +#. module: hr_timesheet_overview +#: model:ir.actions.act_window,name:hr_timesheet_overview.action_hr_employee_hour_report +#: model:ir.ui.menu,name:hr_timesheet_overview.menu_hr_employee_hour_report +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_graph_view +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_pivot_view +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_tree_view +msgid "Hours Report" +msgstr "Rapport d'heures" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_account_analytic_line__id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_contract__id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_resource_calendar__id +msgid "ID" +msgstr "" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_account_analytic_line____last_update +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_contract____last_update +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour____last_update +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report____last_update +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract____last_update +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_resource_calendar____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__write_uid +msgid "Last Updated by" +msgstr "Dernière modification par" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__write_date +msgid "Last Updated on" +msgstr "Dernière modification le" + +#. module: hr_timesheet_overview +#: code:addons/hr_timesheet_overview/models/hr_employee_hour.py:0 +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour__type__leave +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour_report__type__leave +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour_report_abstract__type__leave +#, python-format +msgid "Leave" +msgstr "Absence" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__manager_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__manager_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__manager_id +msgid "Manager" +msgstr "Responsable" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__model_id +msgid "Model" +msgstr "Modèle" + +#. module: hr_timesheet_overview +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +msgid "My report" +msgstr "Mon rapport" + +#. module: hr_timesheet_overview +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +msgid "My team" +msgstr "Mon équipe" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__name_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__name_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__name_id +msgid "Name" +msgstr "Nom" + +#. module: hr_timesheet_overview +#: model_terms:ir.actions.act_window,help:hr_timesheet_overview.action_hr_employee_hour +#: model_terms:ir.actions.act_window,help:hr_timesheet_overview.action_hr_employee_hour_report +msgid "No data yet!" +msgstr "Pas de donnée !" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__percentage +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__percentage +msgid "Percentage" +msgstr "Pourcentage" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__project_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__project_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__project_id +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_search_view +msgid "Project" +msgstr "Projet" + +#. module: hr_timesheet_overview +#: model_terms:ir.actions.act_window,help:hr_timesheet_overview.action_hr_employee_hour +msgid "Regenerate data with the \"Regenerate\" button" +msgstr "Regénérer les données avec le bouton \"Regénérer\"" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,help:hr_timesheet_overview.field_hr_employee_hour__user_id +msgid "Related user name for the resource to manage its access." +msgstr "" + +#. module: hr_timesheet_overview +#: model:ir.model,name:hr_timesheet_overview.model_resource_calendar +msgid "Resource Working Time" +msgstr "" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__res_id +msgid "Ressource ID" +msgstr "Identifiant lié" + +#. module: hr_timesheet_overview +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +msgid "This Year" +msgstr "Cette année" + +#. module: hr_timesheet_overview +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +msgid "This Year Until Today" +msgstr "Cette année jusqu'à aujourd'hui" + +#. module: hr_timesheet_overview +#: code:addons/hr_timesheet_overview/models/hr_employee_hour.py:0 +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour__type__timesheet +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour_report__type__timesheet +#: model:ir.model.fields.selection,name:hr_timesheet_overview.selection__hr_employee_hour_report_abstract__type__timesheet +#, python-format +msgid "Timesheet" +msgstr "Feuilles d'heures" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__type +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__type +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__type +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_search_view +msgid "Type" +msgstr "" + +#. module: hr_timesheet_overview +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +msgid "Until today" +msgstr "Jusqu'à aujourd'hui" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour__user_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report__user_id +#: model:ir.model.fields,field_description:hr_timesheet_overview.field_hr_employee_hour_report_abstract__user_id +#: model_terms:ir.ui.view,arch_db:hr_timesheet_overview.hr_employee_hour_report_search_view +msgid "User" +msgstr "Utilisateur" + +#. module: hr_timesheet_overview +#: model:ir.model.fields,help:hr_timesheet_overview.field_hr_employee_hour__is_invalidated +msgid "Will be reprocessed at next cron execution" +msgstr "" + +#. module: hr_timesheet_overview +#: code:addons/hr_timesheet_overview/models/hr_employee_hour.py:0 +#: model:ir.model.constraint,message:hr_timesheet_overview.constraint_hr_employee_hour_uniqueness +#, python-format +msgid "You can't have two hour lines with same fields: %s" +msgstr "Vous ne pouvez pas avoir plusieurs lignes avec ses champs identiques: %s" diff --git a/hr_timesheet_overview/migrations/15.0.1.1.0/pre-migration.py b/hr_timesheet_overview/migrations/15.0.1.1.0/pre-migration.py new file mode 100644 index 0000000000..13ae13a372 --- /dev/null +++ b/hr_timesheet_overview/migrations/15.0.1.1.0/pre-migration.py @@ -0,0 +1,30 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openupgradelib import openupgrade + +from odoo.tools import parse_version + + +@openupgrade.migrate() +def migrate(env, version): + if parse_version(version) == parse_version("15.0.1.0.0"): + openupgrade.logged_query( + env.cr, + """ALTER TABLE hr_employee_hour ADD COLUMN IF NOT EXISTS name_id VARCHAR;""", + ) + openupgrade.logged_query( + env.cr, """DROP VIEW IF EXISTS hr_employee_hour_report;""" + ) + openupgrade.logged_query( + env.cr, """ALTER TABLE hr_employee_hour DROP COLUMN IF EXISTS name;""" + ) + openupgrade.logged_query( + env.cr, + """ +UPDATE hr_employee_hour +SET name_id = CONCAT(im.model, ',', res_id) +FROM ir_model AS im +WHERE model_id = im.id; +""", + ) diff --git a/hr_timesheet_overview/migrations/15.0.1.2.0/pre-migration.py b/hr_timesheet_overview/migrations/15.0.1.2.0/pre-migration.py new file mode 100644 index 0000000000..3f39f2f452 --- /dev/null +++ b/hr_timesheet_overview/migrations/15.0.1.2.0/pre-migration.py @@ -0,0 +1,29 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openupgradelib import openupgrade + +from odoo.tools import parse_version + + +@openupgrade.migrate() +def migrate(env, version): + if parse_version(version) <= parse_version("15.0.1.1.0"): + openupgrade.logged_query( + env.cr, + """ALTER TABLE hr_employee_hour ADD COLUMN IF NOT EXISTS task_id INTEGER;""", + ) + openupgrade.logged_query( + env.cr, """DROP VIEW IF EXISTS hr_employee_hour_report;""" + ) + openupgrade.logged_query( + env.cr, + """ +UPDATE hr_employee_hour +SET project_id = aal.project_id, task_id = aal.task_id +FROM ( + SELECT id, project_id, task_id + FROM account_analytic_line +) AS aal +WHERE aal.id = res_id AND type in ('timesheet', 'leave');""", + ) diff --git a/hr_timesheet_overview/migrations/15.0.1.3.0/pre-migration.py b/hr_timesheet_overview/migrations/15.0.1.3.0/pre-migration.py new file mode 100644 index 0000000000..38b7f97083 --- /dev/null +++ b/hr_timesheet_overview/migrations/15.0.1.3.0/pre-migration.py @@ -0,0 +1,15 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openupgradelib import openupgrade + +from odoo.tools import parse_version + + +@openupgrade.migrate() +def migrate(env, version): + if parse_version(version) <= parse_version("15.0.1.2.0"): + openupgrade.logged_query( + env.cr, + """DELETE FROM ir_act_window WHERE res_model = 'hr.employee.hour.report';""", + ) diff --git a/hr_timesheet_overview/models/__init__.py b/hr_timesheet_overview/models/__init__.py new file mode 100644 index 0000000000..f2fe355a6d --- /dev/null +++ b/hr_timesheet_overview/models/__init__.py @@ -0,0 +1,5 @@ +from . import hr_employee_hour +from . import hr_employee_hour_mixin +from . import resource_calendar +from . import hr_timesheet +from . import hr_contract diff --git a/hr_timesheet_overview/models/hr_contract.py b/hr_timesheet_overview/models/hr_contract.py new file mode 100644 index 0000000000..a8151ee2bc --- /dev/null +++ b/hr_timesheet_overview/models/hr_contract.py @@ -0,0 +1,51 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + +from ..helpers import generate_dates_from_range, get_end_of_day + + +class Contract(models.Model): + _name = "hr.contract" + _inherit = ["hr.contract", "hr.employee.hour.mixin"] + + def prepare_hr_employee_hour_values( + self, *, date_start=None, date_end=None, exclude_global_leaves=True, **kwargs + ): + model_id = self._get_model_id() + values_list = [] + for contract in self: + ranged_dates = generate_dates_from_range( + date_start or contract.date_start, date_end or contract.date_end + ) + calendar = contract.resource_calendar_id + for date in ranged_dates: + attendances = calendar.attendance_ids.filtered( + lambda att: int(att.dayofweek) == date.weekday() + ) + if not attendances: + continue + # Here, we MUST use datetime as some global leaves are based + # on the previous day at 23h mostly + end_of_day = get_end_of_day(date, attendances) + global_leaves = calendar.global_leave_ids.filtered( + lambda gl: gl.date_from <= end_of_day <= gl.date_to + ) + # We only take valid days of work + if exclude_global_leaves and global_leaves: + continue + time_qty = calendar.get_time_quantities(date=date) + values_list.append( + { + "model_id": model_id, + "res_id": contract.id, + "name_id": f"{contract._name},{contract.id}", + "type": "contract", + "date": date, + "employee_id": contract.employee_id.id, + "hours_qty": time_qty["hours_qty"], + "days_qty": time_qty["days_qty"], + } + ) + return values_list diff --git a/hr_timesheet_overview/models/hr_employee_hour.py b/hr_timesheet_overview/models/hr_employee_hour.py new file mode 100644 index 0000000000..dc43adf6bb --- /dev/null +++ b/hr_timesheet_overview/models/hr_employee_hour.py @@ -0,0 +1,148 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from odoo import _, api, fields, models +from odoo.tools import groupby + +TYPE_SELECTION = [ + ("contract", _("Contract")), + ("timesheet", _("Timesheet")), +] + + +def get_valid_search_fields(sep=None): + """Returns unicity constrained fields for `HrEmployeeHour`""" + fields = "date", "employee_id", "model_id", "res_id", "type" + return sep.join(fields) if sep else fields + + +class HrEmployeeHour(models.Model): + _name = "hr.employee.hour" + _description = "HR Employee Hours per day" + _rec_name = "name_id" + _order = "date" + + active = fields.Boolean(default=True) + date = fields.Date(readonly=True, required=True) + name_id = fields.Reference( + selection="_reference_models", string="Name", readonly=True + ) + type = fields.Selection(TYPE_SELECTION, readonly=True, required=True) + hours_qty = fields.Float(readonly=True, required=True) + days_qty = fields.Float(readonly=True, required=True) + employee_id = fields.Many2one("hr.employee", readonly=True, required=True) + manager_id = fields.Many2one( + "hr.employee", related="employee_id.parent_id", store=True + ) + user_id = fields.Many2one("res.users", related="employee_id.user_id", store=True) + company_id = fields.Many2one( + "res.company", related="employee_id.company_id", store=True + ) + project_id = fields.Many2one("project.project", readonly=True) + task_id = fields.Many2one("project.task", readonly=True) + analytic_group_id = fields.Many2one("account.analytic.group", readonly=True) + model_id = fields.Many2one("ir.model", "Model", readonly=True, ondelete="set null") + res_id = fields.Integer("Ressource ID", readonly=True, required=True) + + _sql_constraints = [ + ( + "uniqueness", + f"UNIQUE({get_valid_search_fields(',')})", + _("You can't have two hour lines with same fields: %s") + % get_valid_search_fields(","), + ) + ] + + @api.model + def _reference_models(self): + models = self.env["ir.model"].search([]) + return [(model.model, model.name) for model in models] + + @api.model + def toggle_active_for_records(self, records, apply_to_name_id=False): + """Toggle active state of line hours depending of records' active field + By default applied on the lines thats matches model_id and res_id, + except if apply_to_name_id option is set. + + :param records: a list of any model records + :param apply_to_name_id: Process only records that match the name_id + """ + sudo_self = self.sudo() + for active, records in groupby(records, lambda r: r.active): + # First, limit the liens that is not in the same active state + domain = [("active", "=", not active)] + if apply_to_name_id: + # Our generate the list of unique name_id + # Using the reference pattern : "model_name,model_id" + generated_name_ids = {f"{r._name},{r.id}" for r in records} + domain.extend([("name_id", "in", list(generated_name_ids))]) + else: + models = sudo_self.env["ir.model"].search( + [("model", "in", records.mapped("_name"))] + ) + domain.extend( + [("model_id", "in", models.ids), ("res_id", "in", records.ids)] + ) + sudo_self.search(domain).write({"active": active}) + + @api.model + def create_or_update(self, vals_list): + """This method aims to allow proper creation or update of a line""" + records = self.browse() + if not vals_list: + return records + if isinstance(vals_list, dict): + vals_list = [vals_list] + sudo_self = self.sudo().with_context(active_test=False) + to_create = [] + for values in vals_list: + # Avoid testing all calls to _prepare...() in case of missing data + if not values: + continue + heh_domain = [ + (field, "=", values.get(field, False)) + # Use of constraint fields to get at most 1 record + for field in get_valid_search_fields() + ] + found_record = sudo_self.search(heh_domain) + # Due to constraint usage, only one record is retrieved + if found_record: + found_record.write(values) + records |= found_record + else: + to_create.append(values) + if to_create: + records |= sudo_self.create(to_create) + return records + + @api.model + def action_generate_data(self, employee_ids=None, date_from=None, date_to=None): + """Launch update process + + :param employee_ids: a list of employee ids + :param date_from: a datetime.date object + (default first contract date, included) + :param date_to: a datetime.date object (default today, included) + """ + if date_to is None: + date_to = datetime.datetime.now() + if not employee_ids: + employees = self.env["hr.employee"].search([]) + else: + employees = self.env["hr.employee"].browse(employee_ids) + if not date_from: + contracts = self.env["hr.contract"].search( + [("employee_id", "in", employees.ids)] + ) + date_from = min(contracts.mapped("date_start"), default=None) + if not date_from: + return + wizard = self.env["wizard.hr.employee.hour.updater"].create( + { + "employee_ids": employees.ids, + "date_from": date_from, + "date_to": date_to, + } + ) + wizard.action_submit() diff --git a/hr_timesheet_overview/models/hr_employee_hour_mixin.py b/hr_timesheet_overview/models/hr_employee_hour_mixin.py new file mode 100644 index 0000000000..9fec1fdb03 --- /dev/null +++ b/hr_timesheet_overview/models/hr_employee_hour_mixin.py @@ -0,0 +1,75 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + +from ..helpers import DEFAULT_TIME_QTY, get_attendances_values_by_date + + +class HrEmployeeHourMixin(models.AbstractModel): + _name = "hr.employee.hour.mixin" + _description = "HR Employee Hour Mixin" + + @api.model + def _get_model_id(self): + return self.env["ir.model"]._get_id(self._name) + + @api.model + def _contract_hours_day(self, employee, date_, cache=None): + try: + if cache: + attendance = cache[employee.id][date_] + else: + attendance = get_attendances_values_by_date( + employee, + date_from=date_, + date_to=date_, + )[employee.id][date_] + except KeyError: + attendance = DEFAULT_TIME_QTY + + hours_qty = attendance["hours_qty"] or DEFAULT_TIME_QTY["hours_qty"] + days_qty = attendance["days_qty"] + return (hours_qty, days_qty) + + def prepare_hr_employee_hour_values(self, **kwargs): + """Return hr employee hour values + :returns: a list of dicts + """ + raise NotImplementedError("HR employee hours mixin implementation error!") + + def update_employee_hours(self, purge=True): + """Regeneration of all related employee hours + :param purge: boolean Removes all previous related employee hours + """ + if purge: + self.hook_unlink_employee_hours() + heh_values = self.prepare_hr_employee_hour_values() + return self.env["hr.employee.hour"].create_or_update(heh_values) + + def hook_unlink_employee_hours(self): + """Purge HR employee hours""" + heh_model = self.env["hr.employee.hour"].sudo().with_context(active_test=False) + heh_domain = [("model_id.model", "=", self._name), ("res_id", "in", self.ids)] + recs = heh_model.search(heh_domain) + recs.unlink() + + def unlink(self): + self.hook_unlink_employee_hours() + return super().unlink() + + def write(self, vals): + result = super().write(vals) + # In case of archiving only, avoid processing the whole regeneration + if list(vals) == ["active"]: + self.env["hr.employee.hour"].toggle_active_for_records(self) + else: + self.sudo().update_employee_hours() + return result + + @api.model_create_multi + def create(self, vals_list): + # Generate employee hours + records = super().create(vals_list) + records.sudo().update_employee_hours() + return records diff --git a/hr_timesheet_overview/models/hr_timesheet.py b/hr_timesheet_overview/models/hr_timesheet.py new file mode 100644 index 0000000000..8f14bfe9f4 --- /dev/null +++ b/hr_timesheet_overview/models/hr_timesheet.py @@ -0,0 +1,68 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class AccountAnalyticLine(models.Model): + _name = "account.analytic.line" + _inherit = ["account.analytic.line", "hr.employee.hour.mixin"] + + def prepare_hr_employee_hour_values(self, **kwargs): + """Prepare hr employee hours for each timesheet. + + Day ratio quantity is a percentage of consumed hours relative to the + attendance day max quantity to make a full day (2h on 8h day is 25%). + + If `attendances_by_date` is not set in context or date is not found, + default hours and day quantity are used + """ + uom_hour = self.env.ref("uom.product_uom_hour") + model_id = self._get_model_id() + values_list = [] + cached_by_date = self.env.context.get("attendances_by_date") + for timesheet in self.filtered("employee_id"): + project = timesheet.project_id + task = timesheet.task_id + contract_hours, contract_day = self._contract_hours_day( + timesheet.employee_id, timesheet.date, cache=cached_by_date + ) + hours_qty = timesheet.unit_amount + if timesheet.product_uom_id != uom_hour: + try: + hours_qty = timesheet.product_uom_id._compute_quantity( + timesheet.unit_amount, uom_hour, round=False + ) + except Exception as e: + # Sometimes, the unit of measure is not properly defined + # and can lead to impossible conversion. + # Instead of blocking lots of other lines to be processed + # Inform by log and pursuit + _logger.error(e) + + # Get the day filled ratio + # If attendance hours qty is empty, it is an extra work day + days_qty = hours_qty / contract_hours + # If hours filled the day then prefer to get ratio from the conf + # To avoid setting a full day if conf says half one only + if days_qty == 1: + days_qty = contract_day + values_list.append( + { + "model_id": model_id, + "res_id": timesheet.id, + "name_id": f"{timesheet._name},{timesheet.id}", + "type": "timesheet", + "date": timesheet.date, + "employee_id": timesheet.employee_id.id, + "project_id": project.id, + "task_id": task.id, + "analytic_group_id": timesheet.group_id.id, + "hours_qty": hours_qty, + "days_qty": days_qty, + } + ) + return values_list diff --git a/hr_timesheet_overview/models/resource_calendar.py b/hr_timesheet_overview/models/resource_calendar.py new file mode 100644 index 0000000000..a87570c77b --- /dev/null +++ b/hr_timesheet_overview/models/resource_calendar.py @@ -0,0 +1,37 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ResourceCalendar(models.Model): + _inherit = "resource.calendar" + + def get_time_quantities(self, date=None): + """Process each attendance by summing morning and afternoon hours + and defining day percentage correspondingly (0.5 if only morning, + 1.0 if both). + + By default, hours_per_day from calendar is returned and 1 full day. + + :param date: filter attendances week day with this date + :return: a dict as follow: { + "hours_qty": .0, + "days_qty": .0; + } + """ + self.ensure_one() + hours_qty = days_qty = 0.0 + attendances = self.attendance_ids + if date: + attendances = attendances.filtered( + lambda att: int(att.dayofweek) == date.weekday() + ) + for attendance in attendances: + hours_qty += attendance.hour_to - attendance.hour_from + if attendance.day_period in ("morning", "afternoon"): + days_qty += 0.5 + return { + "hours_qty": hours_qty or self.hours_per_day, + "days_qty": days_qty or 1.0, + } diff --git a/hr_timesheet_overview/readme/CONTRIBUTORS.md b/hr_timesheet_overview/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..c9839a1a22 --- /dev/null +++ b/hr_timesheet_overview/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Stéphane Mangin +* Florent Xicluna diff --git a/hr_timesheet_overview/readme/DESCRIPTION.md b/hr_timesheet_overview/readme/DESCRIPTION.md new file mode 100644 index 0000000000..148a18a115 --- /dev/null +++ b/hr_timesheet_overview/readme/DESCRIPTION.md @@ -0,0 +1,42 @@ +This module adds a dashboard to manage timesheet and contractual hours. + +# General overview + +The main purpose of this dashboard is to allow employee and manager to have an overview of their working time according to their contract. + +Two dashboard will be needed to have to axes of analyse and will cover the timesheet time and the contractual time of the employee + +# Detailed requirements + +## Dashboard Hours report + +The hours report will allow to calculate the under or over hours of the employee at a day level. For this purpose we need to calculate the contractual hours the employee is requested to do for each day. The sum of the contractual hours, leaves and timesheet will give the time variation according to the contract. + +The contractual hours should be calculated taking in account the following requirements: + +- The calculation per day should take in account the contract valid at the date (for the future if there is no end date for the contract we use the current one) +- The working time per day will use the work plan (resource calendar) on the employee contract with the data per day and if not available the average per day (only the working days) +- Bank holiday should be excluded and they can come from the the calendar or the OCA module Public holiday (if installed) +- Number will be show in negative (in order that the final sum is negative if there is insufficient timesheet according to the contract time +- The timesheet section have the following requirement + + - The timesheet time will be aggregated at project level (and not task as on the timesheet app) + - Working time and time off should be clearly separated (two columns) + - Time off are taken only if fully validated and from the leave object and timesheet created from a leave should be ignored (in order to avoid duplicates) + - By default we filter the current year until today and the user data. + +## Global requirement + +The data coming from the timesheet and time off should always represent the situation we get if we go to the timesheet app or time off app (I mean by here that the data should be in real time). We accept that the data linked to the contract are updated every 24hours. + +At the initialisation the system should be able to generate the past data. + +## Security + +The employee should not see the data from the others employee. +One exception for a manager that can see all the data from employees he is the manager of. + +# Pitfalls + + +- Limit cases about hours on weekend and hours worked at night inbetween 2 days. diff --git a/hr_timesheet_overview/readme/USAGE.md b/hr_timesheet_overview/readme/USAGE.md new file mode 100644 index 0000000000..ee159db07c --- /dev/null +++ b/hr_timesheet_overview/readme/USAGE.md @@ -0,0 +1,10 @@ +To generate employee hours lines at first install: + + - Go to *HR Report Dashboard* > *Update HR Employee hours* + +Then select the employees and the period you want to generate the lines for. +You also select only certain hour types: + +- Contractual hours +- Timesheet hours + diff --git a/hr_timesheet_overview/report/__init__.py b/hr_timesheet_overview/report/__init__.py new file mode 100644 index 0000000000..db65eabd93 --- /dev/null +++ b/hr_timesheet_overview/report/__init__.py @@ -0,0 +1 @@ +from . import hr_employee_hour_report diff --git a/hr_timesheet_overview/report/hr_employee_hour_report.py b/hr_timesheet_overview/report/hr_employee_hour_report.py new file mode 100644 index 0000000000..601f620d37 --- /dev/null +++ b/hr_timesheet_overview/report/hr_employee_hour_report.py @@ -0,0 +1,201 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from collections import defaultdict + +from psycopg2 import sql + +from odoo import api, fields, models, tools + +from ..models.hr_employee_hour import TYPE_SELECTION + + +class AbstractHrEmployeeHourReport(models.AbstractModel): + """This class is intended to be overriden. + + As such, when you change/add a field or override a custom method, you must + call back the init method to properly update your table initialisation. + + + ``` + def init(self): + return super().deferred_init() + ``` + + """ + + _name = "hr.employee.hour.report.abstract" + _description = "Abstract Employee Hour Report" + _order = "date, name_id" + _rec_name = "name_id" + + active = fields.Boolean(default=True) + date = fields.Date() + type = fields.Selection(TYPE_SELECTION) + name_id = fields.Reference( + selection="_reference_models", string="Name", required=True + ) + employee_id = fields.Many2one("hr.employee", "Employee") + manager_id = fields.Many2one("hr.employee", "Manager") + user_id = fields.Many2one("res.users", "User") + company_id = fields.Many2one("res.company", "Company") + project_id = fields.Many2one("project.project", "Project") + task_id = fields.Many2one("project.task", "Task") + analytic_group_id = fields.Many2one("account.analytic.group") + days_qty = fields.Float("Days") + days_qty_abs = fields.Float("Days (abs)") + hours_qty = fields.Float("Hours") + hours_qty_abs = fields.Float("Hours (abs)") + percentage = fields.Float() + + def name_get(self): + result = [] + for record in self: + result.append((record.id, "%s" % (record.name_id.display_name))) + return result + + @api.model + def _reference_models(self): + models = self.env["ir.model"].search([]) + return [(model.model, model.name) for model in models] + + def process_names_from_result(self, result): + """Process the display name of each name references""" + names = [tuple(rec.get("name_id", ",").split(",")) for rec in result] + ids_by_model = defaultdict(set) + for model_name, res_id in names: + if not model_name or not res_id: + continue + ids_by_model[model_name].add(int(res_id)) + names_by_name_value = { + f"{model},{record.id}": record.display_name + for model, ids in ids_by_model.items() + for record in self.env[model].browse(ids) + } + return names_by_name_value + + @api.model + def read_group( + self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True + ): + # Add the percentage values + # Force addition of this fields if not in the view specification + needed_fields = ["hours_qty_abs", "hours_qty_abs:sum"] + if not set(fields).intersection(set(needed_fields)): + fields.append("hours_qty_abs:sum") + result = super().read_group( + domain, fields, groupby, offset, limit, orderby, lazy + ) + + if len(result or ()) < 2: + # Avoid processing if empty or only one result + return result + + def get_grant_total(groups, key): + """Return the sum of this key's values for all records""" + return sum(rec.get(key, 0.0) for rec in groups) + + hours_total = get_grant_total(result, "hours_qty_abs") + names_get_result = self.process_names_from_result(result) + for rec in result: + name = rec.get("name_id", None) + if name: + rec["name_id"] = names_get_result.get(name, f"ERROR: {name}") + if hours_total: + rec["percentage"] = (rec.get("hours_qty_abs", 0.0) / hours_total) * 100 + return result + + def select_hook_custom_fields(self): + """Must end with a comma if not empty""" + return """ + -- But keep absolute values for specific graph calculation + SUM(heh.days_qty) AS days_qty_abs, + SUM(heh.hours_qty) AS hours_qty_abs, + -- Inject negative value for total calculation + SUM(CASE + WHEN heh.type = 'contract' THEN -heh.days_qty + ELSE heh.days_qty + END) AS days_qty, + SUM(CASE + WHEN heh.type = 'contract' THEN -heh.hours_qty + ELSE heh.hours_qty + END) AS hours_qty, + """ + + def _select(self): + return sql.SQL( + f""" + max(heh.id) AS id, + heh.active, + heh.name_id, + heh.project_id, + heh.task_id, + heh.employee_id, + heh.manager_id, + heh.user_id, + heh.company_id, + heh.analytic_group_id, + heh.date, + heh.type, + 0.0 AS percentage, + {self.select_hook_custom_fields()}""".rstrip().rstrip( + "," + ) + ) + + def _from(self): + return sql.SQL("hr_employee_hour AS heh") + + def where_types(self): + return ["contract", "timesheet"] + + def _where(self): + types = sql.SQL(", ").join(map(sql.Literal, self.where_types())) + return sql.SQL("heh.type IN ({})").format(types) + + def group_by_hook_custom_fields(self): + """Must end with a comma if not empty""" + return "" + + def _group_by(self): + return sql.SQL( + f""" + heh.active, + heh.name_id, + heh.project_id, + heh.task_id, + heh.employee_id, + heh.manager_id, + heh.user_id, + heh.company_id, + heh.analytic_group_id, + heh.date, + heh.type, + {self.group_by_hook_custom_fields()}""".rstrip().rstrip( + "," + ) + ) + + def init(self): + request = sql.SQL( + """CREATE OR REPLACE VIEW {} AS ( + SELECT {} + FROM {} + WHERE {} + GROUP BY {} + );""" + ).format( + sql.Identifier(self._table), + self._select(), + self._from(), + self._where(), + self._group_by(), + ) + tools.drop_view_if_exists(self.env.cr, self._table) + self._cr.execute(request) + + +class HrEmployeeHourReport(models.Model): + _name = "hr.employee.hour.report" + _inherit = "hr.employee.hour.report.abstract" + _description = "Employee Hour Report" + _auto = False # Will be processed in init method diff --git a/hr_timesheet_overview/report/hr_employee_hour_report_views.xml b/hr_timesheet_overview/report/hr_employee_hour_report_views.xml new file mode 100644 index 0000000000..63bffd5bcf --- /dev/null +++ b/hr_timesheet_overview/report/hr_employee_hour_report_views.xml @@ -0,0 +1,143 @@ + + + + + hr.employee.hour.report.tree + hr.employee.hour.report + + + + + + + + + + + + + + + + + + + + hr.employee.hour.report.pivot + hr.employee.hour.report + + + + + + + + + + + + + hr.employee.hour.report.graph + hr.employee.hour.report + + + + + + + + + + + + + + + hr.employee.hour.report.search + hr.employee.hour.report + + + + + + + + + + + + + + + + + + + + + + + + + + + Hours Report + hr.employee.hour.report + pivot,graph,tree + + { + 'search_default_filter_my_report': 1, + 'search_default_filter_this_year_until_today': 1, + } + + + +

+ No data yet! +

+
+
+ + + +
diff --git a/hr_timesheet_overview/security/ir.model.access.csv b/hr_timesheet_overview/security/ir.model.access.csv new file mode 100644 index 0000000000..ec9f8bcf49 --- /dev/null +++ b/hr_timesheet_overview/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_employee_hour_user,access_hr_employee_hour_user,model_hr_employee_hour,base.group_user,1,1,1,1 +access_hr_employee_hour_report_user,access_hr_employee_hour_report_user,model_hr_employee_hour_report,base.group_user,1,1,1,1 +access_wizard_hr_employee_hour_updater_user,access_wizard_hr_employee_hour_updater_user,model_wizard_hr_employee_hour_updater,base.group_user,1,1,1,1 diff --git a/hr_timesheet_overview/tests/__init__.py b/hr_timesheet_overview/tests/__init__.py new file mode 100644 index 0000000000..3ee80ad9e1 --- /dev/null +++ b/hr_timesheet_overview/tests/__init__.py @@ -0,0 +1,5 @@ +from . import common +from . import test_helpers +from . import test_hr_employee_hour +from . import test_hr_contract +from . import test_hr_timesheet diff --git a/hr_timesheet_overview/tests/common.py b/hr_timesheet_overview/tests/common.py new file mode 100644 index 0000000000..38869bfb19 --- /dev/null +++ b/hr_timesheet_overview/tests/common.py @@ -0,0 +1,191 @@ +# Copyright 2022 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from datetime import datetime + +from odoo import tools +from odoo.tests.common import TransactionCase + +VALID_DAYS_JAN_2022 = [ + # A tuple forming a day number and its length in days + ((3, 1.0), (4, 1.0), (5, 0.5), (6, 1.0), (7, 1.0)), # week 1 + ((10, 1.0), (11, 1.0), (12, 0.5), (13, 1.0), (14, 1.0)), # week 2 + ((17, 1.0), (18, 1.0), (19, 0.5), (20, 1.0), (21, 1.0)), # week 3 + ((24, 1.0), (25, 1.0), (26, 0.5), (27, 1.0), (28, 1.0)), # week 4 + ((31, 1.0),), # week 5 +] + +INVALID_DAYS_DEC_2021 = [ + (1, 4, 5), # Wednesday # Saturday # Sunday # week 1 + (8, 11, 12), # week 2 + (15, 18, 19), # week 3 + (22, 25, 26), # week 4 + (29,), # week 5 +] + +CURRENT_CONTRACT_HEH_LINES_LENGTH = 21 + 1 # the frozen date +CURRENT_TIMESHEET_HEH_LINES_LENGTH = 21 +CLOSE_CONTRACT_HEH_LINES_LENGTH = 18 +TOTAL_HEH_LINES = ( + CURRENT_CONTRACT_HEH_LINES_LENGTH + + CURRENT_TIMESHEET_HEH_LINES_LENGTH + + CLOSE_CONTRACT_HEH_LINES_LENGTH +) + + +class HrDashboardCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + cls.employee = cls.env["hr.employee"].create( + { + "name": "Sofia Metaclass", + "gender": "female", + "country_id": cls.env.ref("base.us").id, + } + ) + cls.employee._compute_address_id() + cls.full_calendar = cls._define_calendar( + "40 Hours", + [ + (8, 12, 0), + (14, 17, 0), + (8, 12, 1), + (14, 17, 1), + (8, 12, 2), + (8, 12, 3), + (14, 17, 3), + (8, 12, 4), + (14, 17, 4), + ], + "US/Eastern", + ) + cls.reduced_calendar = cls._define_calendar( + "35 Hours", + [ + (8, 12, 0), + (14, 17, 0), + (8, 12, 1), + (14, 17, 1), + (8, 12, 3), + (14, 17, 3), + (8, 12, 4), + (14, 17, 4), + ], + "US/Eastern", + ) + cls.current_contract = cls.create_contract( + cls.employee, cls.full_calendar, datetime(2022, 1, 1).date() + ) + + cls.close_contract = cls.create_contract( + cls.employee, + cls.reduced_calendar, + datetime(2021, 12, 1).date(), + datetime(2021, 12, 31).date(), + ) + + cls.account = cls.env["account.analytic.account"].create( + {"name": "Analytic Account Sample"} + ) + cls.project = cls.env["project.project"].create({"name": "Sample Project"}) + cls.task = cls.env["project.task"].create( + {"name": "Sample Task", "project_id": cls.project.id} + ) + all_days = [] + for week in VALID_DAYS_JAN_2022: + all_days.extend([day for day, length in week]) + cls.timesheets = cls.create_timesheets( + cls.account, cls.project, 2022, 1, all_days, task=cls.task + ) + cls.timesheets_without_employee = cls.create_timesheets( + cls.account, cls.project, 2022, 1, all_days, without_employee=True + ) + + @classmethod + def create_timesheets( + cls, account, project, year, month, days, task=None, without_employee=False + ): + with ( + tools.mute_logger( + "odoo.addons.hr_timesheet_overview.models.hr_employee_hour" + ) + ): + model = cls.env["account.analytic.line"] + records = model.create( + [ + { + "name": "Timesheet Sample", + "date": datetime(year, month, day).date(), + "employee_id": cls.employee.id + if not without_employee + else False, + "unit_amount": 7, + "account_id": account.id, + "project_id": project.id, + "task_id": task.id if task else False, + } + for day in days + ] + ) + records.flush() + # Here we need to override the write and create date of each + # timesheets to allow generation script to search them properly + # pylint: disable=sql-injection + query = ( + "UPDATE account_analytic_line SET create_date=date, " + "write_date=date WHERE id in %s" + ) + cls.env.cr.execute(query, (tuple(records.ids),)) + # pylint: enable=sql-injection + return records + + @classmethod + def create_contract(cls, employee, calendar, start, end=None): + return cls.env["hr.contract"].create( + { + "name": calendar.name, + "employee_id": employee.id, + "state": "close" if end else "open", + "kanban_state": "normal", + "wage": 1, + "date_start": start, + "date_end": end, + "resource_calendar_id": calendar.id, + } + ) + + @classmethod + def _define_calendar(cls, name, attendances, tz): + return cls.env["resource.calendar"].create( + { + "name": name, + "tz": tz, + "attendance_ids": [ + ( + 0, + 0, + { + "name": "%s_%d" % (name, index), + "hour_from": att[0], + "hour_to": att[1], + "dayofweek": str(att[2]), + }, + ) + for index, att in enumerate(attendances) + ], + } + ) + + @classmethod + def _get_related_hours(cls, records, deleted_ids=None): + """Use deleted_id with original id if record has been unlinked""" + ir_model = cls.env["ir.model"].search([("model", "=", records._name)]) + return cls.env["hr.employee.hour"].search( + [ + ("model_id", "=", ir_model.id), + ("res_id", "in", deleted_ids or records.ids), + ] + ) diff --git a/hr_timesheet_overview/tests/test_helpers.py b/hr_timesheet_overview/tests/test_helpers.py new file mode 100644 index 0000000000..7c8dfc6786 --- /dev/null +++ b/hr_timesheet_overview/tests/test_helpers.py @@ -0,0 +1,35 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from odoo.tools import mute_logger + +from odoo.addons.hr_timesheet_overview.helpers import get_attendances_values_by_date + +from .common import ( + CLOSE_CONTRACT_HEH_LINES_LENGTH, + CURRENT_CONTRACT_HEH_LINES_LENGTH, + HrDashboardCommon, +) + + +@freeze_time("2022-02-01") +class HrEmployeeHourUnitTests(HrDashboardCommon): + def test_get_attendances_values_by_date_with_valid_contract(self): + values = get_attendances_values_by_date(self.employee) + oracle = CURRENT_CONTRACT_HEH_LINES_LENGTH + CLOSE_CONTRACT_HEH_LINES_LENGTH + self.assertEqual(oracle, len(values[self.employee.id])) + + def test_get_attendances_values_by_date_with_no_contract(self): + # First purge all timesheets + contracts = self.env["hr.contract"].search( + [("employee_id", "=", self.employee.id)] + ) + with mute_logger("odoo.models.unlink"): + contracts.unlink() + # Then get attendances + values = get_attendances_values_by_date(self.employee) + # Will only return an empty dict for this employee + oracle = {self.employee.id: {}} + self.assertEqual(oracle, values) diff --git a/hr_timesheet_overview/tests/test_hr_contract.py b/hr_timesheet_overview/tests/test_hr_contract.py new file mode 100644 index 0000000000..eae529fb98 --- /dev/null +++ b/hr_timesheet_overview/tests/test_hr_contract.py @@ -0,0 +1,58 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from freezegun import freeze_time + +from .common import ( + CLOSE_CONTRACT_HEH_LINES_LENGTH, + CURRENT_CONTRACT_HEH_LINES_LENGTH, + HrDashboardCommon, +) + + +@freeze_time("2022-02-01") +class HrContractUnitTests(HrDashboardCommon): + def test_create_employee_hours_from_current_contract(self): + heh_lines = self._get_related_hours(self.current_contract) + self.assertEqual(CURRENT_CONTRACT_HEH_LINES_LENGTH, len(heh_lines)) + + def test_create_employee_hours_from_close_contract(self): + heh_lines = self._get_related_hours(self.close_contract) + self.assertEqual(CLOSE_CONTRACT_HEH_LINES_LENGTH, len(heh_lines)) + + def test_update_employee_hours_from_current_contract(self): + # We remove 9 days from start date (5 work days) + minus_days = 5 + self.current_contract.write({"date_start": datetime(2022, 1, 10).date()}) + heh_lines = self._get_related_hours(self.current_contract) + oracle = CURRENT_CONTRACT_HEH_LINES_LENGTH - minus_days + self.assertEqual(oracle, len(heh_lines)) + + def test_update_employee_hours_from_close_contract(self): + # We add 8 days to end date (4 work days) + # And we add 9 days to current contract start (5 work days) + added_days = 4 + # needed to avoid contract overlap + self.current_contract.write({"date_start": datetime(2022, 1, 10).date()}) + self.close_contract.write({"date_end": datetime(2022, 1, 9).date()}) + heh_lines = self._get_related_hours(self.close_contract) + oracle = CLOSE_CONTRACT_HEH_LINES_LENGTH + added_days + self.assertEqual(oracle, len(heh_lines)) + + def test_delete_employee_hours_from_current_contract(self): + deleted_id = self.current_contract.id + self.current_contract.unlink() + heh_lines = self._get_related_hours(self.current_contract, [deleted_id]) + self.assertEqual(0, len(heh_lines)) + + def test_delete_employee_hours_from_close_contract(self): + deleted_id = self.close_contract.id + self.close_contract.unlink() + heh_lines = self._get_related_hours(self.close_contract, [deleted_id]) + self.assertEqual(0, len(heh_lines)) + + def test_prepare_hr_employee_hour_values_should_always_return_a_list(self): + values = self.env["hr.contract"].prepare_hr_employee_hour_values() + self.assertEqual([], values) diff --git a/hr_timesheet_overview/tests/test_hr_employee_hour.py b/hr_timesheet_overview/tests/test_hr_employee_hour.py new file mode 100644 index 0000000000..c7504118a2 --- /dev/null +++ b/hr_timesheet_overview/tests/test_hr_employee_hour.py @@ -0,0 +1,214 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import datetime + +from freezegun import freeze_time + +from odoo.tools import mute_logger + +from .common import ( + INVALID_DAYS_DEC_2021, + TOTAL_HEH_LINES, + VALID_DAYS_JAN_2022, + HrDashboardCommon, +) + + +@freeze_time("2022-02-01") +class HrEmployeeHourUnitTests(HrDashboardCommon): + def test_global_employee_hours_pregenerate_action(self): + heh_model = self.env["hr.employee.hour"] + # Purge previous hours + prev_heh_lines = heh_model.search([("employee_id", "=", self.employee.id)]) + oracle = TOTAL_HEH_LINES + self.assertEqual( + oracle, + len(prev_heh_lines), + f"The pre-generation of employee hours should give {oracle} lines," + f" not {len(prev_heh_lines)}.", + ) + + def test_global_employee_hours_generate_action_idempotent(self): + heh_model = self.env["hr.employee.hour"] + # Purge previous hours + prev_heh_lines = heh_model.search([("employee_id", "=", self.employee.id)]) + oracle = len(prev_heh_lines) + with mute_logger("odoo.models.unlink"): + prev_heh_lines.unlink() + # All possible dates (we can't rely on freeze_gun at this stage) + heh_model.action_generate_data( + self.employee.ids, date_to=datetime(2022, 2, 1).date() + ) + heh_lines = self.env["hr.employee.hour"].search( + [("employee_id", "=", self.employee.id)] + ) + self.assertEqual( + oracle, + len(heh_lines), + "This method 'generate_action' MUST be idempotent from initial" + " generation.", + ) + + def test_global_employee_hours_generate_action_all_possible_dates(self): + heh_model = self.env["hr.employee.hour"] + # Purge previous hours + prev_heh_lines = heh_model.search([("employee_id", "=", self.employee.id)]) + oracle = TOTAL_HEH_LINES + with mute_logger("odoo.models.unlink"): + prev_heh_lines.unlink() + # All possible dates until desired freezed date + heh_model.action_generate_data( + self.employee.ids, date_to=datetime(2022, 2, 1).date() + ) + heh_lines = self.env["hr.employee.hour"].search( + [("employee_id", "=", self.employee.id)] + ) + self.assertEqual( + oracle, + len(heh_lines), + f"This employee {self.employee.name} should have {oracle}" + " hr.employee.hour records defined.", + ) + + def test_global_employee_hours_generate_action_specific_date_to(self): + heh_model = self.env["hr.employee.hour"] + # Purge previous hours + prev_heh_lines = heh_model.search([("employee_id", "=", self.employee.id)]) + with mute_logger("odoo.models.unlink"): + prev_heh_lines.unlink() + # Only a date to + date_to = datetime(2022, 2, 20).date() + heh_model.action_generate_data(self.employee.ids, date_to=date_to) + heh_lines = self.env["hr.employee.hour"].search( + [("employee_id", "=", self.employee.id)] + ) + oracle = 74 + self.assertEqual( + oracle, + len(heh_lines), + f"This employee {self.employee.name} should have {oracle}" + "" + f" hr.employee.hour records defined for a date_to={date_to}.", + ) + + def test_global_employee_hours_generate_action_invalid_ranged_dates(self): + heh_model = self.env["hr.employee.hour"] + # Purge previous hours + prev_heh_lines = heh_model.search([("employee_id", "=", self.employee.id)]) + with mute_logger("odoo.models.unlink"): + prev_heh_lines.unlink() + # Ranged date + date_from = datetime(2012, 1, 1).date() + date_to = datetime(2013, 12, 31).date() + heh_model.action_generate_data( + [self.employee.id], date_from=date_from, date_to=date_to + ) + heh_lines = self.env["hr.employee.hour"].search( + [("employee_id", "=", self.employee.id)] + ) + oracle = 0 + self.assertEqual( + oracle, + len(heh_lines), + f"This ranged dates {date_from}/{date_to} should not generate any" + " employee hour records.", + ) + + def test_prepare_attendance_value_for_unattended_dates(self): + for week in INVALID_DAYS_DEC_2021: + for day in week: + unattended_date = datetime(2021, 12, day).date() + values = self.close_contract.prepare_hr_employee_hour_values( + date_start=unattended_date, date_end=unattended_date + ) + self.assertFalse( + values, + f"This unattended date {unattended_date} should not" + " generate an employee hour for this contract" + "" + f" {self.close_contract.name}", + ) + + def test_prepare_attendance_value_for_attendended_dates(self): + ir_model = self.env["ir.model"].search( + [("model", "=", self.current_contract._name)] + ) + calendar = self.current_contract.resource_calendar_id + for week in VALID_DAYS_JAN_2022: + for day, days_qty in week: + attended_date = datetime(2022, 1, day).date() + date_attendances = calendar.attendance_ids.filtered( + lambda att: int(att.dayofweek) == attended_date.weekday() + ) + hours_per_day_for_date = calendar._compute_hours_per_day( + date_attendances + ) + oracle = { + "date": attended_date, + "days_qty": days_qty, + "employee_id": self.employee.id, + "hours_qty": hours_per_day_for_date, + "model_id": ir_model.id, + "res_id": self.current_contract.id, + "type": "contract", + } + values = self.current_contract.prepare_hr_employee_hour_values( + date_start=attended_date, + date_end=attended_date, + exclude_global_leaves=True, + ) + self.assertEqual( + len(values), + 1, + ) + value = values[0] + self.assertTrue( + value, + f"This attended date {attended_date} should generate an" + " employee hour for this contract" + f" {self.current_contract.name}", + ) + self.assertEqual(oracle["date"], value["date"]) + self.assertEqual(oracle["days_qty"], round(value["days_qty"], 4)) + self.assertEqual(oracle["hours_qty"], value["hours_qty"]) + self.assertEqual(oracle["model_id"], value["model_id"]) + self.assertEqual(oracle["res_id"], value["res_id"]) + self.assertEqual(oracle["type"], value["type"]) + self.assertEqual(oracle["employee_id"], value["employee_id"]) + + def test_prepare_timesheets_lines(self): + timesheet_qty = len(self.timesheets.ids) + values = self.timesheets.prepare_hr_employee_hour_values() + timesheet_days_qty = sum(v["days_qty"] for v in values) + timesheet_hours_qty = sum(v["hours_qty"] for v in values) + self.assertEqual(timesheet_qty, len(values)) + self.assertEqual(24.0, timesheet_days_qty) + self.assertEqual(21 * 7, timesheet_hours_qty) + + def test_contract_name_id(self): + cc = self.current_contract + heh_line = self.env["hr.employee.hour"].search( + [("type", "=", "contract"), ("res_id", "=", cc.id)], limit=1 + ) + self.assertEqual(cc, heh_line.name_id) + + def test_timesheet_name_id(self): + ts = self.timesheets[0] + heh_line = self.env["hr.employee.hour"].search( + [("type", "=", "timesheet"), ("res_id", "=", ts.id)], limit=1 + ) + self.assertEqual(ts, heh_line.name_id) + + def test_timesheet_project_id(self): + ts = self.timesheets[0] + heh_line = self.env["hr.employee.hour"].search( + [("type", "=", "timesheet"), ("res_id", "=", ts.id)], limit=1 + ) + self.assertEqual(self.project, heh_line.project_id) + + def test_timesheet_task_id(self): + ts = self.timesheets[0] + heh_line = self.env["hr.employee.hour"].search( + [("type", "=", "timesheet"), ("res_id", "=", ts.id)], limit=1 + ) + self.assertEqual(self.task, heh_line.task_id) diff --git a/hr_timesheet_overview/tests/test_hr_timesheet.py b/hr_timesheet_overview/tests/test_hr_timesheet.py new file mode 100644 index 0000000000..adf96b02dc --- /dev/null +++ b/hr_timesheet_overview/tests/test_hr_timesheet.py @@ -0,0 +1,41 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from freezegun import freeze_time + +from .common import ( + CURRENT_TIMESHEET_HEH_LINES_LENGTH as timesheet_oracle, + HrDashboardCommon, +) + + +@freeze_time("2022-02-01") +class HrTimesheetUnitTests(HrDashboardCommon): + def test_create_employee_hours_from_timesheet(self): + heh_lines = self._get_related_hours(self.timesheets) + self.assertEqual(timesheet_oracle, len(heh_lines)) + self.assertEqual(timesheet_oracle * 7, sum(heh_lines.mapped("hours_qty"))) + self.assertEqual(24.0, round(sum(heh_lines.mapped("days_qty")), 4)) + + def test_update_employee_hours_from_timesheet(self): + new_hours = 11.81 + self.timesheets.write({"unit_amount": new_hours}) + heh_lines = self._get_related_hours(self.timesheets) + self.assertEqual(timesheet_oracle, len(heh_lines)) + self.assertEqual( + new_hours * timesheet_oracle, sum(heh_lines.mapped("hours_qty")) + ) + self.assertEqual(40.4914, round(sum(heh_lines.mapped("days_qty")), 4)) + + def test_delete_employee_hours_from_timesheet(self): + deleted_ids = self.timesheets.ids + self.timesheets.unlink() + heh_lines = self._get_related_hours(self.timesheets, deleted_ids) + self.assertEqual(0, len(heh_lines)) + + def test_timesheet_without_employee_should_not_return_employee_hour(self): + values = self.timesheets_without_employee.prepare_hr_employee_hour_values() + self.assertEqual([], values) + + def test_prepare_hr_employee_hour_values_should_always_return_a_list(self): + values = self.env["account.analytic.line"].prepare_hr_employee_hour_values() + self.assertEqual([], values) diff --git a/hr_timesheet_overview/views/hr_employee_hour_views.xml b/hr_timesheet_overview/views/hr_employee_hour_views.xml new file mode 100644 index 0000000000..b63933dc0d --- /dev/null +++ b/hr_timesheet_overview/views/hr_employee_hour_views.xml @@ -0,0 +1,86 @@ + + + + + hr.employee.hour.tree + hr.employee.hour + + + + + + + + + + + + + + + + + + + hr.employee.hour.search + hr.employee.hour + + + + + + + + + + + + + + + + + + + + + Contract Hours + hr.employee.hour + tree + + { + 'group_by': [], + 'search_default_filter_date': 1} + + +

+ No data yet! +

+

+ Regenerate data with the "Regenerate" button +

+
+
+ + + +
diff --git a/hr_timesheet_overview/views/menu_views.xml b/hr_timesheet_overview/views/menu_views.xml new file mode 100644 index 0000000000..fcaadb5096 --- /dev/null +++ b/hr_timesheet_overview/views/menu_views.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/hr_timesheet_overview/wizards/__init__.py b/hr_timesheet_overview/wizards/__init__.py new file mode 100644 index 0000000000..40a921a308 --- /dev/null +++ b/hr_timesheet_overview/wizards/__init__.py @@ -0,0 +1 @@ +from . import hr_employee_hour_updater diff --git a/hr_timesheet_overview/wizards/hr_employee_hour_updater.py b/hr_timesheet_overview/wizards/hr_employee_hour_updater.py new file mode 100644 index 0000000000..79cfb3bd44 --- /dev/null +++ b/hr_timesheet_overview/wizards/hr_employee_hour_updater.py @@ -0,0 +1,88 @@ +# Copyright 2023 Camptocamp SA +import logging + +from odoo import fields, models +from odoo.osv import expression + +from ..helpers import get_attendances_values_by_date + +_logger = logging.getLogger(__name__) + + +class WizardHrEmployeeHourUpdater(models.TransientModel): + _name = "wizard.hr.employee.hour.updater" + _description = "Allow to update HR Employee hours" + + employee_ids = fields.Many2many( + "hr.employee", + string="Employees", + default=lambda self: self.env.user.employee_id, + ) + date_from = fields.Date( + "Start date", default=fields.Date.context_today, required=True + ) + date_to = fields.Date("End date", default=fields.Date.context_today, required=True) + timesheet_hours = fields.Boolean("Timesheet hours", default=True) + contract_hours = fields.Boolean("Contract hours", default=True) + + def search_timesheet_domain(self): + """Search filter rules for allocations""" + base_domain = [("employee_id", "in", self.employee_ids.ids)] + date_domain = expression.AND( + [ + [("date", ">=", self.date_from)], + [("date", "<=", self.date_to)], + ] + ) + domain = expression.AND([base_domain, date_domain]) + return domain + + def prepare_values(self): + """Return a list of dict with computed values for this employee""" + values = [] + if self.timesheet_hours: + aal_model = self.env["account.analytic.line"] + search_domain = self.search_timesheet_domain() + timesheets = aal_model.search(search_domain) + _logger.info(f"will process {len(timesheets)} timesheet lines") + values.extend(timesheets.prepare_hr_employee_hour_values()) + if self.contract_hours: + attendances_by_date = self.env.context.get("attendances_by_date") + if not attendances_by_date: + attendances_by_date = get_attendances_values_by_date( + self.employee_ids, self.date_from, self.date_to + ) + for emp_vals in attendances_by_date.values(): + if not emp_vals: + continue + assert self.date_from <= min(emp_vals) + assert self.date_to >= max(emp_vals) + values.extend(emp_vals.values()) + return values + + def action_submit(self): + if not self.employee_ids: + self.employee_ids = self.env["hr.employee"].search([]) + # Preloading all the needed attendances for days and hours quantities + # It's really time consuming depending of employees and dates selection + # But not as much as timesheets calculation + attendances_by_date = get_attendances_values_by_date( + self.employee_ids, self.date_from, self.date_to + ) + values = self.with_context( + attendances_by_date=attendances_by_date, active_test=False + ).prepare_values() + records = ( + self.env["hr.employee.hour"] + .with_context(active_test=False) + .create_or_update(values) + ) + + return { + "type": "ir.actions.act_window", + "name": "Updated HR Employee hours", + "view_mode": "tree", + "res_model": "hr.employee.hour", + "domain": [("id", "in", records.ids)], + "target": "current", + } diff --git a/hr_timesheet_overview/wizards/hr_employee_hour_updater_view.xml b/hr_timesheet_overview/wizards/hr_employee_hour_updater_view.xml new file mode 100644 index 0000000000..8ea002d7c1 --- /dev/null +++ b/hr_timesheet_overview/wizards/hr_employee_hour_updater_view.xml @@ -0,0 +1,54 @@ + + + + Update HR Employee hours + wizard.hr.employee.hour.updater + +
+ + + + + + + + + + + + + +
+
+
+
+
+ + + Update HR Employee hours + wizard.hr.employee.hour.updater + form + new + + + + +
diff --git a/setup/hr_timesheet_overview/odoo/addons/hr_timesheet_overview b/setup/hr_timesheet_overview/odoo/addons/hr_timesheet_overview new file mode 120000 index 0000000000..2dbef51497 --- /dev/null +++ b/setup/hr_timesheet_overview/odoo/addons/hr_timesheet_overview @@ -0,0 +1 @@ +../../../../hr_timesheet_overview \ No newline at end of file diff --git a/setup/hr_timesheet_overview/setup.py b/setup/hr_timesheet_overview/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/hr_timesheet_overview/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)