3 Commits

39 changed files with 1657 additions and 984 deletions

View File

@@ -3,7 +3,7 @@
{
"name": "allow_negative_leave_and_allocation",
"version": "16.0.1.0.1",
"version": "16.0.1.0.2",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Elabore",

View File

@@ -15,5 +15,4 @@ class HrLeaveAllocation(models.Model):
"(holiday_type='department' AND department_id IS NOT NULL) or "
"(holiday_type='company' AND mode_company_id IS NOT NULL))",
"The employee, department, company or employee category of this request is missing. Please make sure that your user login is linked to an employee."),
('duration_check', "CHECK((allocation_type != 'regular'))", "The duration must be greater than 0."),
]

View File

@@ -0,0 +1,55 @@
=======================
hr_employee_stats_sheet
=======================
Summary
=======
Add global sheet for employee stats
Description
===========
This module add a global sheet stats in timesheet sheet
It compare planified working hours (from calendar ressouce) and worked hours form timesheets
Installation
============
Use Odoo normal module installation procedure to install
``hr_employee_stats_sheet``.
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `our issues website <https://github.com/elabore-coop/allow_negative_leave_and_allocation/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.
Credits
=======
Contributors
------------
* `Alusage : Nicolas JEUDY`
* `Elabore <mailto:laetitia.dacosta@elabore.coop>`
Funders
-------
The development of this module has been financially supported by:
* Alusage (https://github.com/Alusage)
* Elabore (https://elabore.coop)
Maintainer
----------
This module is maintained by Elabore.

View File

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

View File

@@ -0,0 +1,27 @@
{
"name": "hr_employee_stats_sheet",
"version": "16.0.2.0.0",
"description": "Add global sheet for employee stats",
"summary": "Add global sheet for employee stats",
"author": "Nicolas JEUDY",
"website": "https://github.com/Alusage/odoo-hr-addons",
"license": "LGPL-3",
"category": "Human Resources",
"depends": [
"allow_negative_leave_and_allocation",
"base",
"hr",
"hr_holidays",
"hr_timesheet",
"hr_timesheet_sheet",
"resource",
],
"data": [
"security/ir.model.access.csv",
"views/hr_employee_stats.xml",
"views/hr_timesheet_sheet.xml",
"views/res_config_settings_views.xml",
],
"installable": True,
"application": False,
}

View File

@@ -0,0 +1,446 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_employee_stats_sheet
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-19 14:31+0000\n"
"PO-Revision-Date: 2025-06-19 14:31+0000\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: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.res_config_settings_view_form
msgid ""
"<span class=\"fa fa-lg fa-building\" title=\"Values set here are company-"
"specific.\" aria-label=\"Values set here are company-specific.\" "
"groups=\"base.group_multi_company\" role=\"img\"/>"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_needaction
msgid "Action Needed"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_ids
msgid "Activities"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_state
msgid "Activity State"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_attachment_count
msgid "Attachment Count"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_res_company__auto_validate_recovery_allocation
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_res_config_settings__auto_validate_recovery_allocation
msgid "Auto validate recovery allocation"
msgstr "Auto-validation des allocations de récupération"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.res_config_settings_view_form
msgid "Choose the coef to apply to recovery hours."
msgstr "Indiquer le coefficient multiplicateur à appliquer sur les heures supplémentaires (en %) pour générer les heures de récupération. Par défaut : 25,00%"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.res_config_settings_view_form
msgid "Choose the recovery type."
msgstr ""
"Sélectionner le type de congé utilisé pour générer des allocations de "
"récupération. Le type de congé doit accepter les allocations négatives et être pris à l'heure."
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.res_config_settings_view_form
msgid "Choose to auto-validate the recovery allocation or not"
msgstr "Validation automatique des allocations de récupération"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_res_company__coef
msgid "Coef"
msgstr "Coefficient"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_res_config_settings__coef
msgid "Coef (in %)"
msgstr "Coefficient (en %)"
#. module: hr_employee_stats_sheet
#: model:ir.model,name:hr_employee_stats_sheet.model_res_company
msgid "Companies"
msgstr "Sociétés"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__company_id
msgid "Company"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model,name:hr_employee_stats_sheet.model_res_config_settings
msgid "Config Settings"
msgstr "Paramètres de configuration"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.view_hr_timesheet_sheet_form_inherit
msgid "Create recovery allocation"
msgstr "Générer une récupération"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__create_uid
msgid "Created by"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__create_date
msgid "Created on"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__date
msgid "Date"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__dayofweek
msgid "Day of Week"
msgstr "Jour de la semaine"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__department_id
msgid "Department"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__display_name
msgid "Display Name"
msgstr ""
#. module: hr_employee_stats_sheet
#. odoo-python
#: code:addons/hr_employee_stats_sheet/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"Employe not defined for the timesheet sheet or recovery type not defined in "
"settings"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__employee_id
msgid "Employee"
msgstr "Employé·e"
#. module: hr_employee_stats_sheet
#: model:ir.model,name:hr_employee_stats_sheet.model_hr_employee_stats
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_timesheet_sheet__employee_stats_ids
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.view_hr_timesheet_sheet_form_inherit
msgid "Employee Stats"
msgstr "Statistique des employé·e·s"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.hr_timesheet_sheet_stats_overview_pivot_view
msgid "Employee time stats pivot"
msgstr "Statistique de l'employé·e"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__failed_message_ids
msgid "Failed Messages"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_follower_ids
msgid "Followers"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_partner_ids
msgid "Followers (Partners)"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__gap_hours
msgid "Gap Hours"
msgstr "Heures d'écart"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.view_hr_timesheet_sheet_form_inherit
msgid "Generated recovery allocations"
msgstr "Allocation(s) de récupération générée(s)"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__has_message
msgid "Has Message"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__id
msgid "ID"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_exception_icon
msgid "Icon"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__message_needaction
msgid "If checked, new messages require your attention."
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__message_has_error
msgid "If checked, some messages have a delivery error."
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_is_follower
msgid "Is Follower"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats____last_update
msgid "Last Modified on"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__write_uid
msgid "Last Updated by"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__write_date
msgid "Last Updated on"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_res_company__recovery_type_id
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_res_config_settings__recovery_type_id
msgid "Leave recovery type"
msgstr "Type de congé pour les récupérations"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_main_attachment_id
msgid "Main Attachment"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_has_error
msgid "Message Delivery error"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_ids
msgid "Messages"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__name
msgid "Name"
msgstr "Nom"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_calendar_event_id
msgid "Next Activity Calendar Event"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_date_deadline
msgid "Next Activity Deadline"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_summary
msgid "Next Activity Summary"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_type_id
msgid "Next Activity Type"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_needaction_counter
msgid "Number of Actions"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__message_has_error_counter
msgid "Number of errors"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__message_needaction_counter
msgid "Number of messages requiring action"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__is_public_holiday
msgid "Public Holiday"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_timesheet_sheet__recovery_allocation_ids
msgid "Recovery Allocations"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__activity_user_id
msgid "Responsible User"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_res_config_settings__coef
msgid ""
"The coef is applied to recovery hoursExample : an employe make 1h overtime, "
"if coef is set to 25%, the recovery hours allocated will be 1,25h"
msgstr ""
#. module: hr_employee_stats_sheet
#. odoo-python
#: code:addons/hr_employee_stats_sheet/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"The number of recovery hours exceeds the authorized limit (%s h) by %s hours"
msgstr ""
#. module: hr_employee_stats_sheet
#. odoo-python
#: code:addons/hr_employee_stats_sheet/models/hr_timesheet_sheet.py:0
#, python-format
msgid ""
"There is a contract starting during the timesheet sheet time period for the "
"employee %sPlease create a new timesheet sheet starting from the new "
"contract start date"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model,name:hr_employee_stats_sheet.model_hr_leave_allocation
msgid "Time Off Allocation"
msgstr "Allocation de congés"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__sheet_id
msgid "Timesheet"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model,name:hr_employee_stats_sheet.model_hr_timesheet_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_leave_allocation__timesheet_sheet_id
msgid "Timesheet Sheet"
msgstr "Feuille de temps"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__timesheet_line_ids
msgid "Timesheet lines"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_timesheet_sheet__timesheet_sheet_gap_hours
msgid "Timesheet sheet gap hours"
msgstr "Heures d'écart sur la période de la feuille de temps"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_timesheet_sheet__timesheet_sheet_recovery_hours
msgid "Timesheet sheet recovery hours"
msgstr "Heures de récupération sur la période de la feuille de temps"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.view_hr_employee_stats_tree
msgid "Total"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__total_hours
msgid "Total Hours"
msgstr "Heures comptabilisées"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__total_leave_hours
msgid "Total Leave Hours"
msgstr "Heures de congés"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__total_planned_hours
msgid "Total Planning Hours"
msgstr "Heures planifiées"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__total_recovery_hours
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_timesheet_sheet__total_recovery_hours
msgid "Total Recovery Hours"
msgstr "Heures de récupération"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.view_hr_timesheet_sheet_form_inherit
msgid "Total gap hours in this timesheet sheet time range"
msgstr "Total heures d'écart sur cette période de temps"
#. module: hr_employee_stats_sheet
#: model_terms:ir.ui.view,arch_db:hr_employee_stats_sheet.view_hr_timesheet_sheet_form_inherit
msgid "Total recovery hours in this timesheet sheet time range"
msgstr ""
"Total heures de repos compensateur accumulé sur cette période de temps"
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,field_description:hr_employee_stats_sheet.field_hr_employee_stats__website_message_ids
msgid "Website Messages"
msgstr ""
#. module: hr_employee_stats_sheet
#: model:ir.model.fields,help:hr_employee_stats_sheet.field_hr_employee_stats__website_message_ids
msgid "Website communication history"
msgstr ""

View File

@@ -0,0 +1,5 @@
from . import hr_employee_stats
from . import hr_timesheet_sheet
from . import res_config
from . import res_company
from . import hr_leave_allocation

View File

@@ -0,0 +1,189 @@
import logging
from odoo import api, fields, models
from datetime import timedelta
_logger = logging.getLogger(__name__)
class HrEmployeeStats(models.Model):
_name = "hr.employee.stats"
_description = "Employee Stats"
_order = "date desc"
_inherit = ["mail.thread", "mail.activity.mixin"]
name = fields.Char("Name", compute="_compute_name", store=True)
dayofweek = fields.Integer("Day of Week", compute="_compute_dayofweek")
is_public_holiday = fields.Boolean("Public Holiday", compute="_compute_dayofweek")
employee_id = fields.Many2one("hr.employee", "Employee", required=True)
department_id = fields.Many2one("hr.department", "Department")
timesheet_line_ids = fields.One2many(
"account.analytic.line",
"employee_id",
"Timesheet lines",
compute="_compute_timesheet_line_ids",
)
date = fields.Date("Date", required=True)
company_id = fields.Many2one(
"res.company",
"Company",
default=lambda self: self.env.company,
required=True,
)
sheet_id = fields.Many2one("hr_timesheet.sheet", "Timesheet")
total_hours = fields.Float("Total Hours", compute="_compute_hours")
total_planned_hours = fields.Float("Total Planning Hours", compute="_compute_hours")
total_leave_hours = fields.Float("Total Leave Hours", compute="_compute_hours")
total_recovery_hours = fields.Float(
"Total Recovery Hours", compute="_compute_hours"
)
gap_hours = fields.Float("Gap Hours", compute="_compute_hours")
def _get_holiday_status_id(self):
recovery_type_id = self.env.company.recovery_type_id
if recovery_type_id:
return recovery_type_id.id
else:
return False
def _compute_timesheet_line_ids(self):
for stat in self:
stat.timesheet_line_ids = self.env["account.analytic.line"].search(
[
("employee_id", "=", stat.employee_id.id),
("date", "=", stat.date),
]
)
def _get_intersects(
self, datetime1_start, datetime1_end, datetime2_start, datetime2_end
):
latest_start = max(datetime1_start, datetime2_start)
earliest_end = min(datetime1_end, datetime2_end)
delta = (earliest_end - latest_start).total_seconds() / 3600
return max(0, delta)
def get_total_hours_domain(self):
return [
("employee_id", "=", self.employee_id.id),
("date", "=", self.date),
]
@api.depends("timesheet_line_ids")
def _get_total_hours(self):
timesheet_line = self.env["account.analytic.line"]
for stat in self:
if stat.date and stat.employee_id:
timesheet_line_ids = timesheet_line.search(
stat.get_total_hours_domain()
)
total_hours = sum(timesheet_line_ids.mapped("unit_amount"))
else:
total_hours = 0
return total_hours
def _get_total_planned_hours(self):
for stat in self:
if stat.employee_id and stat.date and not stat.is_public_holiday:
dayofweek = int(stat.date.strftime("%u")) - 1
calendar_id = stat.employee_id.resource_calendar_id
week_number = stat.date.isocalendar()[1] % 2
if calendar_id.two_weeks_calendar:
hours = calendar_id.attendance_ids.search(
[
("dayofweek", "=", dayofweek),
("calendar_id", "=", calendar_id.id),
("week_type", "=", week_number),
]
)
else:
hours = calendar_id.attendance_ids.search(
[
("dayofweek", "=", dayofweek),
("calendar_id", "=", calendar_id.id),
]
)
total_planned_hours = sum(
hours.mapped(lambda r: r.hour_to - r.hour_from)
)
else:
total_planned_hours = 0
return total_planned_hours
def _get_total_recovery_hours(self):
recovery = self.env["hr.leave"]
for stat in self:
if stat.date and stat.employee_id and stat._get_holiday_status_id():
recovery_ids = recovery.search(
[
("employee_id", "=", stat.employee_id.id),
("request_date_from", ">=", stat.date),
("request_date_from", "<=", stat.date),
("holiday_status_id", "=", stat._get_holiday_status_id()),
]
)
total_recovery_hours = sum(
recovery_ids.mapped("number_of_hours_display")
)
else:
total_recovery_hours = 0
return total_recovery_hours
def _get_total_leave_hours(self):
total_leave_hours = 0
for stat in self:
if stat.date and stat.employee_id:
leave_ids = self.env["hr.leave"].search(
[
("employee_id", "=", stat.employee_id.id),
("holiday_status_id", "!=", stat._get_holiday_status_id()),
("request_date_from", ">=", stat.date),
("request_date_to", "<=", stat.date),
]
)
total_leave_hours = sum(leave_ids.mapped("number_of_hours_display"))
return total_leave_hours
@api.depends("employee_id", "date")
def _compute_name(self):
for stat in self:
stat.name = "%s - %s" % (stat.employee_id.name, stat.date)
@api.depends("date","employee_id")
def _compute_dayofweek(self):
for stat in self:
if not stat.date:
stat.dayofweek = None
stat.is_public_holiday = False
continue
stat.dayofweek = int(stat.date.strftime("%u")) - 1
stat.is_public_holiday = bool(stat.sheet_id.employee_id._get_public_holidays(stat.date, stat.date - timedelta(days=1)))
def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours):
self.ensure_one()
balance = (
total_hours
+ total_recovery_hours
+ total_leave_hours
- total_planned_hours
)
return balance
@api.depends(
"employee_id",
"date",
"total_hours",
"total_planned_hours",
"timesheet_line_ids",
)
def _compute_hours(self):
for stat in self:
total_hours = stat._get_total_hours()
total_recovery_hours = stat._get_total_recovery_hours()
total_planned_hours = stat._get_total_planned_hours()
total_leave_hours = stat._get_total_leave_hours()
stat.total_hours = total_hours
stat.total_planned_hours = total_planned_hours
stat.gap_hours = stat._get_gap_hours(total_hours, total_recovery_hours, total_leave_hours, total_planned_hours)
stat.total_recovery_hours = total_recovery_hours
stat.total_leave_hours = total_leave_hours

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class HrLeaveAllocation(models.Model):
_inherit = "hr.leave.allocation"
timesheet_sheet_id = fields.Many2one("hr_timesheet.sheet", string="Timesheet Sheet", readonly=True)

View File

@@ -0,0 +1,265 @@
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons.resource.models.resource import HOURS_PER_DAY
class HrTimesheetSheet(models.Model):
_inherit = "hr_timesheet.sheet"
employee_stats_ids = fields.One2many(
"hr.employee.stats", "sheet_id", "Employee Stats"
)
timesheet_sheet_gap_hours = fields.Float(
"Timesheet sheet gap hours", compute="_compute_hours"
)
timesheet_sheet_recovery_hours = fields.Float(
"Timesheet sheet recovery hours", compute="_compute_hours"
)
recovery_allocation_ids = fields.One2many(
'hr.leave.allocation',
'timesheet_sheet_id',
string='Recovery Allocations',
)
@api.depends("employee_stats_ids.gap_hours", "employee_id")
def _compute_hours(self):
for sheet in self:
sheet.timesheet_sheet_gap_hours = sheet._get_timesheet_sheet_gap_hours()
sheet.timesheet_sheet_recovery_hours = sheet._get_timesheet_sheet_recovery_hours()
def _get_timesheet_sheet_gap_hours(self):
for sheet in self:
timesheet_sheet_gap_hours = sum(
sheet.employee_stats_ids.filtered(
lambda stat: stat.date <= fields.Date.today()
).mapped("gap_hours")
)
return timesheet_sheet_gap_hours
def _get_timesheet_sheet_recovery_hours(self):
self.ensure_one()
coef = self.env.company.coef
# apply coef only if hours to recovery are positive and coef is existing
if self.timesheet_sheet_gap_hours > 0 and coef > 0:
timesheet_sheet_recovery_hours = self.timesheet_sheet_gap_hours + (
self.timesheet_sheet_gap_hours * coef / 100
)
else:
timesheet_sheet_recovery_hours = self.timesheet_sheet_gap_hours
return timesheet_sheet_recovery_hours
def search_and_create_employee_stats(self):
for sheet in self:
if sheet.employee_id:
for day in range((sheet.date_end - sheet.date_start).days + 1):
date = sheet.date_start + timedelta(days=day)
stats = self.env["hr.employee.stats"].search(
[
("employee_id", "=", sheet.employee_id.id),
("date", "=", date),
]
)
if stats and not stats.sheet_id:
stats.write({"sheet_id": sheet.id})
if not stats:
self.env["hr.employee.stats"].create(
{
"employee_id": sheet.employee_id.id,
"date": date,
"sheet_id": sheet.id,
"company_id": sheet.company_id.id,
}
)
return True
@api.model
def create(self, vals):
res = super().create(vals)
res.search_and_create_employee_stats()
return res
def write(self, vals):
res = super().write(vals)
if "date_end" in vals or "date_start" in vals or "employee_id" in vals:
self.search_and_create_employee_stats()
return res
def unlink(self):
for sheet in self:
sheet.employee_stats_ids.unlink()
return super().unlink()
def action_timesheet_draft(self):
res = super().action_timesheet_draft()
for sheet in self:
# set the state of the recovery allocation to refuse if the timesheet sheet is set to draft
if sheet.state == "draft":
sheet.recovery_allocation_ids.write({"state": "refuse"})
return res
def _get_contracts_in_progress_during_timesheet_sheet_time_period(self):
"""
get the contracts which was in progress during the timesheet sheet range time
"""
contracts = self.env["hr.contract"].search(
[
("employee_id", "=", self.employee_id.id),
("state", "in", ("open", "close")),
("date_start", "<=", self.date_end),
"|",
("date_end", "=", False), # pas de date de fin OU
("date_end", ">=", self.date_start), # date de fin après le début
],
)
return contracts
def _get_calendar_in_progress_during_timesheet_time_period(self):
"""
get the ressource calendar which was used during the timesheet sheet time period
"""
#checks if only one contract runs over the duration of the timesheet
contracts = self._get_contracts_in_progress_during_timesheet_sheet_time_period()
if len(contracts) > 1:
# check if a new contract start during timesheet sheet time period. If yes, raise an error
raise UserError(
_("There is a contract starting during the timesheet sheet time period for the employee %s"
"Please create a new timesheet sheet starting from the new contract start date")
% self.employee_id.display_name
)
# get the ressource calendar id according to the work contract
elif self.employee_id.resource_calendar_id:
return self.employee_id.resource_calendar_id
elif self.env.company.resource_calendar_id:
return self.env.company.resource_calendar_id
return None
def _get_working_hours_per_week(self):
"""
Get the weekly working hours for the employee, which is defined by:
- the employee's work contract,
- or their resource calendar,
- or the company's resource calendar,
- or the default value of 40 hours per week.
:return: limit recovery hours
"""
# get ressource calendar id used during the timesheet sheet time period
ressource_calendar_id = self._get_calendar_in_progress_during_timesheet_time_period()
if ressource_calendar_id:
resource_calendar_attendance_ids = self.env[
"resource.calendar.attendance"
].search([("calendar_id", "=", ressource_calendar_id.id)])
# calculate working hours per week according to the employee's resource calendar
weekly_working_hours = 0
for day in resource_calendar_attendance_ids:
weekly_working_hours += day.hour_to - day.hour_from
return weekly_working_hours
return HOURS_PER_DAY * 5
def _get_working_hours_per_day(self):
"""
Get the hours per day for the employee according to:
- the employee's work contract,
- or their resource calendar,
- or the company's resource calendar,
- or the default value of 8 hours per day.
:return: hours per day
"""
# get ressource calendar id used during the timesheet sheet time period
ressource_calendar_id = self._get_calendar_in_progress_during_timesheet_time_period()
if ressource_calendar_id:
return ressource_calendar_id.hours_per_day
return HOURS_PER_DAY
def _get_max_allowed_recovery_hours(self):
"""
Get the maximum number of hours beyond which new recovery allowances cannot be created
"""
return self._get_working_hours_per_week()
def action_generate_recovery_allocation(self):
# check if the user has the right to review the timesheet sheet
self.ensure_one()
self._check_can_review()
recovery_type_id = self.env.company.recovery_type_id
employee_id = self.employee_id
if not employee_id or not recovery_type_id:
raise UserError(
_("Employe not defined for the timesheet sheet or recovery type not defined in settings")
)
if recovery_type_id.request_unit != 'hour' or not recovery_type_id.allows_negative:
raise UserError(
_("The recovery type must be set to 'Hours' and allow negative leaves in the settings")
)
# check if allocation already exists for this timesheet sheet, if yes, refuse it
allocations = (
self.env["hr.leave.allocation"]
.sudo()
.search(
[
("timesheet_sheet_id.id", "=", self.id),
("state", "!=", "refuse"),
]
)
)
if allocations:
allocations.write({"state": "refuse"})
# get recovery hours from total gap hours of the timesheet sheet
recovery_hours = self._get_timesheet_sheet_recovery_hours()
# get recovery hours cap
max_allowed_recovery_hours = self._get_max_allowed_recovery_hours()
if max_allowed_recovery_hours:
# find recovery remaining leaves for the employee
recovery_type_id = self.env.company.recovery_type_id
# get virtual remaining leaves for the employee and the recovery leaves type
data_days = recovery_type_id.get_employees_days([employee_id.id])[employee_id.id]
total_recovery_type_leaves = data_days.get(recovery_type_id.id,{})
total_virtual_remaining_recovery_type_leaves = total_recovery_type_leaves.get('virtual_remaining_leaves', 0)
# add the recovery hours to the total remaining leaves recovery type, and check if the limit of recovery hours is exceeded
exceeded_hours = total_virtual_remaining_recovery_type_leaves + recovery_hours - max_allowed_recovery_hours
# if limit recovery hours is exceeded, don't create a new allocation
if exceeded_hours > 0:
raise UserError(
_(
"The number of recovery hours exceeds the authorized limit (%s h) by %s hours"
)
% (max_allowed_recovery_hours, exceeded_hours)
)
# convert recovery hours into days
recovery_days = recovery_hours / self._get_working_hours_per_day()
# create an allocation (positive or negative) according to the total gap hours for the timesheet sheet range time
new_alloc = self.env["hr.leave.allocation"].create(
{
"private_name": "Allocation : %s - %s" % (recovery_type_id.name, employee_id.display_name),
"holiday_status_id": recovery_type_id.id,
"employee_id": employee_id.id,
"number_of_days": recovery_days,
"timesheet_sheet_id": self.id,
"allocation_type": 'accrual',
}
)
# set the allocation to validate or not
if self.env.company.auto_validate_recovery_allocation:
new_alloc.write({"state": "validate"})
return {
"type": "ir.actions.act_window",
"res_model": "hr.leave.allocation",
"res_id": new_alloc.id,
"view_mode": "form",
"target": "current",
"views": [
(
self.env.ref("hr_holidays.hr_leave_allocation_view_form").id,
"form",
)
],
}

View File

@@ -0,0 +1,12 @@
from odoo import fields, models
class ResCompany(models.Model):
_inherit = "res.company"
recovery_type_id = fields.Many2one(
"hr.leave.type", string="Leave recovery type"
)
coef = fields.Float("Coef", default=25)
auto_validate_recovery_allocation = fields.Boolean("Auto validate recovery allocation", default=True)

View File

@@ -0,0 +1,29 @@
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
recovery_type_id = fields.Many2one(
"hr.leave.type",
related="company_id.recovery_type_id",
string="Leave recovery type",
readonly=False,
domain="[('request_unit', '=', 'hour'), ('allows_negative', '=', True)]",
)
coef = fields.Float(
related="company_id.coef",
required=True,
string="Coef (in %)",
domain="[('company_id', '=', company_id)]",
readonly=False,
help="The coef is applied to recovery hours"
"Example : an employe make 1h overtime, if coef is set to 25%, the recovery hours allocated will be 1,25h",
)
auto_validate_recovery_allocation = fields.Boolean(
related="company_id.auto_validate_recovery_allocation",
readonly=False,
domain="[('company_id', '=', company_id)]",
)

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_employee_stats_manager,hr.employee.stats,model_hr_employee_stats,hr_timesheet.group_timesheet_manager,1,1,1,1
access_hr_employee_stats_user,hr.employee.stats,model_hr_employee_stats,hr_timesheet.group_hr_timesheet_user,1,0,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_employee_stats_manager hr.employee.stats model_hr_employee_stats hr_timesheet.group_timesheet_manager 1 1 1 1
3 access_hr_employee_stats_user hr.employee.stats model_hr_employee_stats hr_timesheet.group_hr_timesheet_user 1 0 1 0

View File

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

View File

@@ -0,0 +1,254 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from datetime import date, timedelta
from odoo.exceptions import UserError
@tagged("post_install", "-at_install")
class TestHrEmployeeStatsRecovery(TransactionCase):
def setUp(self):
super().setUp()
self.user = self.env['res.users'].create({
'name': 'Camille',
'login': 'camille',
})
self.recovery_type = self.env['hr.leave.type'].create({
'name': 'Recovery',
'request_unit': 'hour',
'allows_negative': True,
})
self.employee = self.env['hr.employee'].create({
'name': 'Camille',
'user_id': self.user.id,
})
self.base_calendar = self.env['resource.calendar'].create({
'name': 'Default Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.employee.resource_calendar_id = self.base_calendar
self.env.company.coef = 25
def _create_timesheet_sheet(self, start_date):
#Crée une feuille de temps pour la semaine du lundi au dimanche
return self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': start_date,
'date_end': start_date + timedelta(days=6),
})
def _create_stats(self, start_date, nb_days, unit_amount):
# Crée des temps du lundi au vendredi (ou nb_days)
for i in range(nb_days):
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
'date': start_date + timedelta(days=i),
'unit_amount': unit_amount,
'account_id': 1,
'name': 'Work Entry',
})
# Génère les hr_employee_stats pour chaque jour de la période
stat = self.env['hr.employee.stats'].create({
'employee_id': self.employee.id,
'date': start_date + timedelta(days=i),
})
stat._compute_hours()
yield stat
def test_invalide_recovery_type(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
self.recovery_type.request_unit = 'day'
self.recovery_type.allows_negative = False
timesheet_sheet = self._create_timesheet_sheet(start_date)
self.env.company.recovery_type_id = self.recovery_type
with self.assertRaises(UserError):
timesheet_sheet.action_generate_recovery_allocation()
def test_no_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 5, 7): #créer 5 stats de 7h chacune
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour
self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # l'employé a travaillé 7h chaque jour
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour
self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # pas de différence entre les heures travaillées et les heures planifiées
# La feuille de temps sur cette période doit compter 0h de déficit et 0h de récupération
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 0, "timesheet_sheet_gap_hours should be 0",)
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 0, "timesheet_sheet_recovery_hours should be 0",)
def test_positive_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 5, 8): #créer 5 stats de 8h chacune
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour
self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 8h chaque jour
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour
self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de plus que prévu
# La feuille de temps doit compter 5h d'heure sup soit 6,25h de récupération avec la majoration de 25%
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 5, "timesheet_sheet_gap_hours should be 5",)
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 6.25, "timesheet_sheet_recovery_hours should be 6,25",)
# générer l'allocation de récupération depuis la feuille de temps
timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
self.assertEqual(recovery_allocation.number_of_days,0.78125, "The recovery allocation should be for 6.25h/8h = 0.78125 day")
def test_negative_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 5, 6): #créer 5 stats de 6h chacune
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour
self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # l'employé a travaillé 6h chaque jour
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour
self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # l'employée a travaillé une heure de moins que prévu
# La feuille de temps doit compter -5h de déficit et -5h de récupération
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, -5, "timesheet_sheet_gap_hours should be -5",) # l'employé a travaillé -5h au total sur la semaine
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, -5, "timesheet_sheet_recovery_hours should be -5",) # -5h sera le montant de l'allocation de récupération (pas de coef appliqué pour les déficites d'heures)
# générer l'allocation de récupération depuis la feuille de temps
timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
self.assertEqual(recovery_allocation.number_of_days, -0.625, "The recovery allocation should be for -5/8 hours = ")
def test_recovery_hours_part_time_employee(self):
part_time_calendar = self.env['resource.calendar'].create({
'name': 'Part Time Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.employee.resource_calendar_id = part_time_calendar.id
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date)
for stat in self._create_stats(start_date, 4, 8): #créer 4 stats de 8h chacune
# Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour pendant 4 jours
self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 6h chaque jour
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour
self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de moins que prévu
# La feuille de temps doit compter 4h d'heure sup soit 5h de récupération avec la majoration de 25%
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) # l'employé a travaillé supplémentaire 4h au total sur la semaine
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h sera le montant de l'allocation de récupération (coef de 25% de majoration)
def test_recovery_hours_change_contract(self):
part_time_calendar = self.env['resource.calendar'].create({
'name': 'Part Time Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
#create one contract ending on wednesday and one other starting on thursday
self.env['hr.contract'].create({
'name': 'Contract 1',
'employee_id': self.employee.id,
'date_start': date.today() - timedelta(days=300), # date de début factice
'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière
'resource_calendar_id': self.base_calendar.id,
'wage': 2000,
'state': 'close',
})
self.env['hr.contract'].create({
'name': 'Contract 2',
'employee_id': self.employee.id,
'state': 'open',
'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # date de début le jeudi de la semaine dernière
'date_end': False,
'resource_calendar_id': self.base_calendar.id,
'wage': 1500,
})
self.employee.resource_calendar_id = part_time_calendar.id
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
#create a timesheet with period including the change of contract
timesheet_sheet = self._create_timesheet_sheet(start_date)
#the create of recovery allocation should raise an error
with self.assertRaises(UserError):
timesheet_sheet.action_generate_recovery_allocation()
def test_recovery_hours_change_contract_sucess(self):
part_time_calendar = self.env['resource.calendar'].create({
'name': 'Part Time Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
#create one contract ending on wednesday and one other starting on thursday
self.env['hr.contract'].create({
'name': 'Contract 1',
'employee_id': self.employee.id,
'date_start': date(2025,8,18),
'date_end': date(2025,8,24),
'resource_calendar_id': self.base_calendar.id,
'wage': 2000,
'state': 'close',
})
self.env['hr.contract'].create({
'name': 'Contract 2',
'employee_id': self.employee.id,
'state': 'open',
'date_start': date(2025,8,25),
'date_end': date(2025,8,31),
'resource_calendar_id': part_time_calendar.id,
'wage': 1500,
})
self.employee.resource_calendar_id = part_time_calendar.id
#create a timesheet with period including the change of contract
timesheet_sheet_1 = self._create_timesheet_sheet(date(2025,8,18))
timesheet_sheet_2 = self._create_timesheet_sheet(date(2025,8,25))
timesheet_sheet_1.action_generate_recovery_allocation()
timesheet_sheet_2.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")

View File

@@ -0,0 +1,20 @@
<odoo>
<record id="view_hr_employee_stats_tree" model="ir.ui.view">
<field name="name">hr.employee.stats.tree</field>
<field name="model">hr.employee.stats</field>
<field name="arch" type="xml">
<tree name="Employee time stats" editable="top" decoration-muted="dayofweek in [5,6] or is_public_holiday">
<field name="name"/>
<field name="date"/>
<field name="employee_id"/>
<field name="total_hours" widget="float_time" sum="Total"/>
<field name="total_leave_hours" widget="float_time" sum="Total"/>
<field name="total_recovery_hours" widget="float_time" sum="Total"/>
<field name="total_planned_hours" widget="float_time" sum="Total"/>
<field name="gap_hours" widget="float_time" sum="Total"/>
<field name="dayofweek" invisible="1"/>
<field name="is_public_holiday" invisible="1"/>
</tree>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,42 @@
<odoo>
<data>
<record id="view_hr_timesheet_sheet_form_inherit" model="ir.ui.view">
<field name="name">hr.timesheet.sheet.form.inherit</field>
<field name="model">hr_timesheet.sheet</field>
<field name="inherit_id" ref="hr_timesheet_sheet.hr_timesheet_sheet_form"/>
<field name="arch" type="xml">
<group name="dates" position="inside">
<field name="timesheet_sheet_gap_hours" string="Total gap hours in this timesheet sheet time range" widget="float_time"/>
<field name="timesheet_sheet_recovery_hours" string="Total recovery hours in this timesheet sheet time range" widget="float_time" style="font-weight: bold"/>
<button
colspan="2"
class="oe_highlight"
name="action_generate_recovery_allocation"
string='Create recovery allocation'
type='object'
attrs="{'invisible': ['|', ('can_review', '=', False),
('state', '!=', 'done'),
('timesheet_sheet_recovery_hours', '=', 0)]}"
/>
</group>
<xpath expr="//notebook" position="inside">
<page string="Employee Stats">
<field name="employee_stats_ids" no_label="1" readonly="1"/>
</page>
<page string="Generated recovery allocations">
<field name="recovery_allocation_ids" no_label="1" readonly="1"/>
</page>
</xpath>
</field>
</record>
<record id="hr_timesheet_sheet_stats_overview_pivot_view" model="ir.ui.view">
<field name="name">hr.timesheet.sheet.stats.overview.pivot.view</field>
<field name="model">hr_timesheet.sheet</field>
<field name="arch" type="xml">
<pivot string="Employee time stats pivot" sample="1">
<field name="employee_id" type="row"/>
</pivot>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,60 @@
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.hr.timesheet</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="55" />
<field name="inherit_id" ref="hr_timesheet.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='hr_timesheet_sheet']" position="inside">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="recovery_type_id"/>
<span
class="fa fa-lg fa-building"
title="Values set here are company-specific."
aria-label="Values set here are company-specific."
groups="base.group_multi_company"
role="img"
/>
<div class="text-muted">
Choose the recovery type.
</div>
<div class="content-group">
<div class="mb16">
<field
name="recovery_type_id"
class="o_light_label"
widget="selection"
required="1"
/>
</div>
</div>
<div class="text-muted">
Choose the coef to apply to recovery hours.
</div >
<div class="content-group">
<div class="mb16">
<field
name="coef"
class="o_light_label"
widget="float"
/> %
</div>
</div>
<div class="text-muted">
Choose to auto-validate the recovery allocation or not
</div>
<div class="content-group">
<div class="mb16">
<field
name="auto_validate_recovery_allocation"
widget="boolean_toggle"
/>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,55 @@
============================
hr_timesheet_sheet_usability
============================
Summary
=======
Various changes to improve the usability of hr_timesheet_sheet application
Description
===========
This module changes :
- menu, menu order, menu access right
- french translation
of hr_timesheet_sheet module
Installation
============
Use Odoo normal module installation procedure to install
``hr_timesheet_sheet_usability``.
Known issues / Roadmap
======================
None yet.
Bug Tracker
===========
Bugs are tracked on `our issues website <https://github.com/elabore-coop/allow_negative_leave_and_allocation/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.
Credits
=======
Contributors
------------
* `Elabore <mailto:contact@elabore.coop>`
Funders
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
Maintainer
----------
This module is maintained by Elabore.

View File

View File

@@ -0,0 +1,19 @@
{
"name": "hr_timesheet_sheet_usability",
"version": "16.0.1.0.0",
"description": "Various changes to improve the usability of hr_timesheet_sheet application",
"summary": "",
"author": "Elabore",
"website": "https://github.com/Alusage/odoo-hr-addons",
"license": "LGPL-3",
"category": "Human Resources",
"depends": [
"base","hr_timesheet","hr_timesheet_sheet",
],
"data": [
"views/hr_timesheet_sheet_menu.xml",
"views/account_analytic_line_views.xml",
],
"installable": True,
"application": False,
}

View File

@@ -0,0 +1,80 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-19 09:03+0000\n"
"PO-Revision-Date: 2025-06-19 09:03+0000\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: \n"
"Plural-Forms: \n"
#. module: hr_timesheet
#: model:ir.actions.act_window,name:hr_timesheet.timesheet_action_all
#: model:ir.ui.menu,name:hr_timesheet.timesheet_menu_activity_all
msgid "All Timesheets"
msgstr "Tous les temps"
#. module: hr_timesheet
#: model:ir.actions.act_window,name:hr_timesheet.act_hr_timesheet_line
#: model:ir.ui.menu,name:hr_timesheet.timesheet_menu_activity_mine
#: model:ir.ui.menu,name:hr_timesheet.timesheet_menu_activity_user
#: model_terms:ir.ui.view,arch_db:hr_timesheet.hr_timesheet_line_search
#: model_terms:ir.ui.view,arch_db:hr_timesheet.hr_timesheet_report_search
msgid "My Timesheets"
msgstr "Mes temps"
#. module: hr_timesheet
#. odoo-python
#: code:addons/hr_timesheet/models/hr_timesheet.py:0
#: code:addons/hr_timesheet/models/project.py:0
#: model:ir.actions.act_window,name:hr_timesheet.act_hr_timesheet_line_by_project
#: model:ir.actions.act_window,name:hr_timesheet.timesheet_action_from_employee
#: model:ir.actions.report,name:hr_timesheet.timesheet_report
#: model:ir.actions.report,name:hr_timesheet.timesheet_report_project
#: model:ir.actions.report,name:hr_timesheet.timesheet_report_task
#: model:ir.actions.report,name:hr_timesheet.timesheet_report_task_timesheets
#: model:ir.model.fields,field_description:hr_timesheet.field_project_project__allow_timesheets
#: model:ir.model.fields,field_description:hr_timesheet.field_project_task__timesheet_ids
#: model:ir.ui.menu,name:hr_timesheet.menu_hr_time_tracking
#: model:ir.ui.menu,name:hr_timesheet.menu_timesheets_reports_timesheet
#: model:ir.ui.menu,name:hr_timesheet.timesheet_menu_root
#: model_terms:ir.ui.view,arch_db:hr_timesheet.hr_department_view_kanban
#: model_terms:ir.ui.view,arch_db:hr_timesheet.portal_layout
#: model_terms:ir.ui.view,arch_db:hr_timesheet.portal_my_home_timesheet
#: model_terms:ir.ui.view,arch_db:hr_timesheet.portal_my_task
#: model_terms:ir.ui.view,arch_db:hr_timesheet.portal_my_timesheets
#: model_terms:ir.ui.view,arch_db:hr_timesheet.project_invoice_form
#: model_terms:ir.ui.view,arch_db:hr_timesheet.project_project_view_form_simplified_inherit_timesheet
#: model_terms:ir.ui.view,arch_db:hr_timesheet.project_sharing_inherit_project_task_view_form
#: model_terms:ir.ui.view,arch_db:hr_timesheet.report_timesheet
#: model_terms:ir.ui.view,arch_db:hr_timesheet.report_timesheet_task
#: model_terms:ir.ui.view,arch_db:hr_timesheet.res_config_settings_view_form
#: model_terms:ir.ui.view,arch_db:hr_timesheet.timesheet_project_task_page
#: model_terms:ir.ui.view,arch_db:hr_timesheet.timesheets_analysis_report_graph_employee
#: model_terms:ir.ui.view,arch_db:hr_timesheet.timesheets_analysis_report_graph_project
#: model_terms:ir.ui.view,arch_db:hr_timesheet.timesheets_analysis_report_graph_task
#: model_terms:ir.ui.view,arch_db:hr_timesheet.view_hr_timesheet_line_graph
#: model_terms:ir.ui.view,arch_db:hr_timesheet.view_hr_timesheet_line_pivot
#: model_terms:ir.ui.view,arch_db:hr_timesheet.view_project_kanban_inherited
#: model_terms:ir.ui.view,arch_db:hr_timesheet.view_task_form2_inherited
#, python-format
msgid "Timesheets"
msgstr "Temps"
#. module: hr_timesheet_sheet
#: model:ir.actions.act_window,name:hr_timesheet_sheet.act_hr_timesheet_sheet_all_timesheets
#: model:ir.ui.menu,name:hr_timesheet_sheet.menu_act_hr_timesheet_sheet_all_timesheets
msgid "All Timesheet Sheets"
msgstr "Toutes les feuilles de temps"
#. module: hr_timesheet_sheet
#: model:ir.ui.menu,name:hr_timesheet_sheet.menu_hr_my_timesheets
msgid "My Timesheets"
msgstr "Feuilles de temps"

View File

@@ -0,0 +1,26 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * hr_timesheet_sheet_usability
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-15 08:39+0000\n"
"PO-Revision-Date: 2025-09-15 08:39+0000\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: hr_timesheet_sheet_usability
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_usability.view_hr_timesheet_sheet_group_by_inherit
msgid "Date de départ"
msgstr ""
#. module: hr_timesheet_sheet_usability
#: model_terms:ir.ui.view,arch_db:hr_timesheet_sheet_usability.view_hr_timesheet_sheet_group_by_inherit
msgid "Timesheet by Date"
msgstr ""

View File

@@ -0,0 +1,11 @@
<odoo>
<!-- Hide menu "Timesheets to Submit" -->
<record id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit" model="ir.ui.menu">
<field name="active">False</field>
</record>
<!-- Hide menu "My Timesheets to Submit" -->
<record id="hr_timesheet_sheet.menu_act_hr_timesheet_line_to_submit_my" model="ir.ui.menu">
<field name="active">False</field>
</record>
</odoo>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0"?>
<odoo>
<!-- add date group by date in timesheet sheet view -->
<record id="view_hr_timesheet_sheet_group_by_inherit" model="ir.ui.view">
<field name="name">hr_timesheet.sheet.filter</field>
<field name="model">hr_timesheet.sheet</field>
<field
name="inherit_id"
ref="hr_timesheet_sheet.view_hr_timesheet_sheet_filter"
/>
<field name="arch" type="xml">
<group position="inside">
<filter string="Date de départ" name="groupby_date" domain="[]" context="{'group_by': 'date_start'}" help="Timesheet by Date"/>
</group>
</field>
</record>
<!-- Make menu "My Timesheets to Submit" visible only for managers -->
<menuitem
id="hr_timesheet_sheet.menu_hr_to_review"
groups="hr_timesheet.group_timesheet_manager"
name="To Review"
parent="hr_timesheet.timesheet_menu_root"
sequence="7"
/>
<menuitem
id="hr_timesheet_sheet.menu_act_hr_timesheet_sheet_to_review"
groups="hr_timesheet.group_timesheet_manager"
action="hr_timesheet_sheet.act_hr_timesheet_sheet_to_review"
parent="hr_timesheet_sheet.menu_hr_to_review"
sequence="11"
/>
<!-- Move All timesheets menu under Timesheet menu -->
<menuitem
id="hr_timesheet_sheet.menu_act_hr_timesheet_sheet_all_timesheets"
parent="hr_timesheet_sheet.menu_hr_my_timesheets"
/>
<!-- Cancel timesheet line menus move and restore their original placement from hr_timesheet module -->
<record model="ir.ui.menu" id="hr_timesheet.timesheet_menu_activity_mine">
<field name="parent_id" ref="hr_timesheet.menu_hr_time_tracking" />
</record>
<record model="ir.ui.menu" id="hr_timesheet.timesheet_menu_activity_user">
<field name="parent_id" ref="hr_timesheet.timesheet_menu_root" />
</record>
</odoo>

View File

@@ -1,41 +0,0 @@
===================
l10n_fr_hr_holidays
===================
Manages French specificities for leaves and holidays, specialy for part-time employees.
Installation
============
Use Odoo normal module installation procedure to install
``l10n_fr_hr_holidays``.
Configuration
=============
In settings, select the leaves type on wich you want to manage the french specificities.
Known issues / Roadmap
======================
None yet.
Credits
=======
Contributors
------------
* `Elabore <mailto:contacnt@elabore.coop>`
Funders
-------
The development of this module has been financially supported by:
* Elabore (https://elabore.coop)
Maintainer
----------
This module is maintained by Elabore.

View File

@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
"name": "France - Time Off",
"version": "16.0.1.0.0",
"category": "Human Resources/Time Off",
"countries": ["fr"],
"summary": "Management of leaves for part-time workers in France",
"depends": ["hr_holidays", "l10n_fr","resource"],
"auto_install": True,
"license": "LGPL-3",
"data": [
"views/res_config_settings_views.xml",
],
"demo": [
"data/l10n_fr_hr_holidays_demo.xml",
],
}

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="l10n_fr_part_time_calendar" model="resource.calendar">
<field name="name">Part time</field>
<field name="company_id" eval="False"/>
<field name="hours_per_day">9</field>
<field name="attendance_ids"
eval="[(5, 0, 0),
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 18.0, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 18.0, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 18.0, 'day_period': 'afternoon'}),
]"
/>
</record>
<record id="l10n_fr_part_time_employee" model="hr.employee">
<field name="company_id" ref="l10n_fr.demo_company_fr"/>
<field name="active" eval="1"/>
<field name="name">Mitchell Admin</field>
<field name="user_id" ref="base.user_admin"/>
<field name="resource_calendar_id" ref="l10n_fr_part_time_calendar"/>
<field name="image_1920" eval="obj(ref('base.partner_admin')).image_1920" model="res.partner"/>
</record>
<record id="l10n_fr_holiday_status_cl" model="hr.leave.type">
<field name="name">Paid Time Off</field>
<field name="company_id" ref="l10n_fr.demo_company_fr"/>
<field name="requires_allocation">yes</field>
<field name="employee_requests">no</field>
<field name="leave_validation_type">both</field>
<field name="allocation_validation_type">officer</field>
<field name="responsible_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="icon_id" ref="hr_holidays.icon_14"/>
<field name="color">2</field>
<field name="has_valid_allocation">True</field>
</record>
<record id="l10n_fr.demo_company_fr" model="res.company">
<field name="l10n_fr_reference_leave_type" ref="l10n_fr_holiday_status_cl"/>
</record>
<record id="l10n_fr_hr_holidays_allocation" model="hr.leave.allocation">
<field name="name">Paid Time Off allocation</field>
<field name="state">confirm</field>
<field name="holiday_status_id" ref="l10n_fr_holiday_status_cl"/>
<field name="number_of_days">20</field>
<field name="date_from" eval="time.strftime('%Y-01-01')"/>
<field name="date_to" eval="time.strftime('%Y-12-31')"/>
<field name="employee_id" ref="l10n_fr_part_time_employee"/>
<field name="employee_ids" eval="[(4, ref('l10n_fr_part_time_employee'))]"/>
</record>
<function model="hr.leave.allocation" name="action_validate">
<value eval="[ref('l10n_fr_hr_holidays_allocation')]"/>
</function>
</odoo>

View File

@@ -1,8 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_leave
from . import res_company
from . import resource_calendar
from . import res_config_settings
from . import utils

View File

@@ -1,188 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import fields, models, _
from odoo.exceptions import UserError
class HrLeave(models.Model):
_inherit = 'hr.leave'
resource_calendar_id = fields.Many2one('resource.calendar', compute='_compute_resource_calendar_id', store=True, readonly=False, copy=False)
company_id = fields.Many2one('res.company', compute='_compute_company_id', store=True)
l10n_fr_date_to_changed = fields.Boolean()
def _compute_company_id(self):
for holiday in self:
holiday.company_id = holiday.employee_company_id \
or holiday.mode_company_id \
or holiday.department_id.company_id \
or self.env.company
def _compute_resource_calendar_id(self):
for leave in self:
calendar = False
if leave.holiday_type == 'employee':
calendar = leave.employee_id.resource_calendar_id
# YTI: Crappy hack: Move this to a new dedicated hr_holidays_contract module
# We use the request dates to find the contracts, because date_from
# and date_to are not set yet at this point. Since these dates are
# used to get the contracts for which these leaves apply and
# contract start- and end-dates are just dates (and not datetimes)
# these dates are comparable.
if 'hr.contract' in self.env and leave.employee_id:
contracts = self.env['hr.contract'].search([
'|', ('state', 'in', ['open', 'close']),
'&', ('state', '=', 'draft'),
('kanban_state', '=', 'done'),
('employee_id', '=', leave.employee_id.id),
('date_start', '<=', leave.request_date_to),
'|', ('date_end', '=', False),
('date_end', '>=', leave.request_date_from),
])
if contracts:
# If there are more than one contract they should all have the
# same calendar, otherwise a constraint is violated.
calendar = contracts[:1].resource_calendar_id
elif leave.holiday_type == 'department':
calendar = leave.department_id.company_id.resource_calendar_id
elif leave.holiday_type == 'company':
calendar = leave.mode_company_id.resource_calendar_id
leave.resource_calendar_id = calendar or self.env.company.resource_calendar_id
def _l10n_fr_leave_applies(self):
# The french l10n is meant to be computed only in very specific cases:
# - there is only one employee affected by the leave
# - the company is french
# - the leave_type is the reference leave_type of that company
self.ensure_one()
return self.employee_id and \
self.company_id.country_id.code == 'FR' and \
self.resource_calendar_id != self.company_id.resource_calendar_id and \
self.holiday_status_id == self.company_id._get_fr_reference_leave_type()
def _get_fr_date_from_to(self, date_from, date_to):
self.ensure_one()
# What we need to compute is how much we will need to push date_to in order to account for the lost days
# This gets even more complicated in two_weeks_calendars
# The following computation doesn't work for resource calendars in
# which the employee works zero hours.
if not (self.resource_calendar_id.attendance_ids):
raise UserError(_("An employee cannot take a paid time off in a period they work no hours."))
if self.request_unit_half and self.request_date_from_period == 'am':
# In normal workflows request_unit_half implies that date_from and date_to are the same
# request_unit_half allows us to choose between `am` and `pm`
# In a case where we work from mon-wed and request a half day in the morning
# we do not want to push date_to since the next work attendance is actually in the afternoon
date_from_weektype = str(self.env['resource.calendar.attendance'].get_week_type(date_from))
date_from_dayofweek = str(date_from.weekday())
# Get morning and afternoon attendances for that day
attendances_am = self.resource_calendar_id.attendance_ids.filtered(lambda a:
a.dayofweek == date_from_dayofweek
and a.day_period == 'morning'
and (not self.resource_calendar_id.two_weeks_calendar or a.week_type == date_from_weektype))
attendances_pm = self.resource_calendar_id.attendance_ids.filtered(lambda a:
a.dayofweek == date_from_dayofweek
and a.day_period == 'afternoon'
and (not self.resource_calendar_id.two_weeks_calendar or a.week_type == date_from_weektype))
if attendances_am and not attendances_pm:
# If the employee does not work in the afternoon, postpone date_to to the next working day
next_date = date_from + relativedelta(days=1)
while not self.resource_calendar_id._works_on_date(next_date):
next_date += relativedelta(days=1)
return (date_from, next_date)
elif attendances_am and attendances_pm:
# The employee also works in the afternoon, no postponement
return (date_from, date_to)
# Special handling for two-weeks calendars
if self.resource_calendar_id.two_weeks_calendar:
# Count the number of days actually worked by the employee between date_from and date_to
current_date = date_from
days_count = 0
while current_date <= date_to:
if self.resource_calendar_id._works_on_date(current_date):
days_count += 1
current_date += relativedelta(days=1)
# Adjust date_to so it matches the expected number of days
# If the expected number of days is less than the period, reduce date_to
if days_count > 0:
# Find the date_to that gives the right number of worked days
current_date = date_from
counted = 0
while counted < days_count:
if self.resource_calendar_id._works_on_date(current_date):
counted += 1
if counted == days_count:
break
current_date += relativedelta(days=1)
return (date_from, current_date)
# Check calendars for working days until we find the right target, start at date_to + 1 day
# Postpone date_target until the next working day
date_start = date_from
date_target = date_to
# It is necessary to move the start date up to the first work day of
# the employee calendar as otherwise days worked on by the company
# calendar before the actual start of the leave would be taken into
# account.
while not self.resource_calendar_id._works_on_date(date_start):
date_start += relativedelta(days=1)
while not self.resource_calendar_id._works_on_date(date_target + relativedelta(days=1)):
date_target += relativedelta(days=1)
# Undo the last day increment
return (date_start, date_target)
def _compute_date_from_to(self):
super()._compute_date_from_to()
for leave in self:
if leave._l10n_fr_leave_applies():
new_date_from, new_date_to = leave._get_fr_date_from_to(leave.date_from, leave.date_to)
if new_date_from != leave.date_from:
leave.date_from = new_date_from
if new_date_to != leave.date_to:
leave.date_to = new_date_to
leave.l10n_fr_date_to_changed = True
else:
leave.l10n_fr_date_to_changed = False
#@overwrite
def _get_calendar(self):
"""
In France, paid time off for part-time employees is counted on the company's working days (not the employee's own schedule).
The company's calendar must be used for the legal leave day count.
"""
self.ensure_one()
if self._l10n_fr_leave_applies():
return self.company_id.resource_calendar_id or self.env.company.resource_calendar_id
return super()._get_calendar()
#@overwrite
def _get_number_of_days_batch(self, date_from, date_to, employee_ids):
"""
Returns a dict with the number of legal leave days for each employee,
based on the company's calendar. In France, part-time employees accrue and take leave on company working days,
not only on their own working days. Handles half-day requests and rounds according to French rules.
"""
employee = self.env['hr.employee'].browse(employee_ids)
# Force the company in the domain, as we are likely in a compute_sudo context
domain = [
('time_type', '=', 'leave'),
('company_id', 'in', self.env.company.ids + self.env.context.get('allowed_company_ids', []))
]
calendar = self._get_calendar()
result = employee._get_work_days_data_batch(date_from, date_to, calendar=calendar, domain=domain)
for employee_id in result:
# For non-French context: a half-day leave always counts as 0.5 day
if self.request_unit_half and result[employee_id]['hours'] > 0 and not self._l10n_fr_leave_applies():
result[employee_id]['days'] = 0.5
# For French context: round the number of days to the nearest half-day (legal rule)
elif self.request_unit_half and result[employee_id]['hours'] > 0 and self._l10n_fr_leave_applies():
result[employee_id]['days'] = self._round_to_nearest_half(result[employee_id]['days'])
return result
def _round_to_nearest_half(self, x):
"""Round a float to the nearest 0.5."""
return round(x * 2) / 2

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import ValidationError
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_fr_reference_leave_type = fields.Many2one(
'hr.leave.type',
string='Company Paid Time Off Type')
def _get_fr_reference_leave_type(self):
self.ensure_one()
if not self.l10n_fr_reference_leave_type:
raise ValidationError(_("You must first define a reference time off type for the company."))
return self.l10n_fr_reference_leave_type

View File

@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
l10n_fr_reference_leave_type = fields.Many2one(
'hr.leave.type',
related='company_id.l10n_fr_reference_leave_type',
readonly=False)
# backport from V170
company_country_code = fields.Char(related="company_id.country_id.code", string="Company Country Code", readonly=True)

View File

@@ -1,26 +0,0 @@
# -*- coding:utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from collections import defaultdict
class ResourceCalendar(models.Model):
_inherit = 'resource.calendar'
def _works_on_date(self, date):
self.ensure_one()
working_days = self._get_working_hours()
dayofweek = str(date.weekday())
if self.two_weeks_calendar:
weektype = str(self.env['resource.calendar.attendance'].get_week_type(date))
return working_days[weektype][dayofweek]
return working_days[False][dayofweek]
def _get_working_hours(self):
self.ensure_one()
working_days = defaultdict(lambda: defaultdict(lambda: False))
for attendance in self.attendance_ids:
working_days[attendance.week_type][attendance.dayofweek] = True
return working_days

View File

@@ -1,197 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
from datetime import time
from itertools import chain
from pytz import utc
from odoo import fields
from odoo.osv.expression import normalize_domain, is_leaf, NOT_OPERATOR
from odoo.tools.float_utils import float_round
# Default hour per day value. The one should
# only be used when the one from the calendar
# is not available.
HOURS_PER_DAY = 8
# This will generate 16th of days
ROUNDING_FACTOR = 16
def make_aware(dt):
""" Return ``dt`` with an explicit timezone, together with a function to
convert a datetime to the same (naive or aware) timezone as ``dt``.
"""
if dt.tzinfo:
return dt, lambda val: val.astimezone(dt.tzinfo)
return dt.replace(tzinfo=utc), lambda val: val.astimezone(utc).replace(tzinfo=None)
def string_to_datetime(value):
""" Convert the given string value to a datetime in UTC. """
return utc.localize(fields.Datetime.from_string(value))
def datetime_to_string(dt):
""" Convert the given datetime (converted in UTC) to a string value. """
return fields.Datetime.to_string(dt.astimezone(utc))
def float_to_time(hours):
""" Convert a number of hours into a time object. """
if hours == 24.0:
return time.max
fractional, integral = math.modf(hours)
return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
def _boundaries(intervals, opening, closing):
""" Iterate on the boundaries of intervals. """
for start, stop, recs in intervals:
if start < stop:
yield (start, opening, recs)
yield (stop, closing, recs)
def filter_domain_leaf(domain, field_check, field_name_mapping=None):
"""
filter_domain_lead only keep the leaves of a domain that verify a given check. Logical operators that involves
a leaf that is undetermined (because it does not pass the check) are ignored.
each operator is a logic gate:
- '&' and '|' take two entries and can be ignored if one of them (or the two of them) is undetermined
-'!' takes one entry and can be ignored if this entry is undetermined
params:
- domain: the domain that needs to be filtered
- field_check: the function that the field name used in the leaf needs to verify to keep the leaf
- field_name_mapping: dictionary of the form {'field_name': 'new_field_name', ...}. Occurences of 'field_name'
in the first element of domain leaves will be replaced by 'new_field_name'. This is usefull when adapting a
domain from one model to another when some field names do not match the names of the corresponding fields in
the new model.
returns: The filtered version of the domain
"""
domain = normalize_domain(domain)
field_name_mapping = field_name_mapping or {}
stack = [] # stack of elements (leaf or operator) to conserve (reversing it gives a domain)
ignored_elems = [] # history of ignored elements in the domain (not added to the stack)
# if the top of the stack ignored_elems is:
# - True: indicates that the last browsed elem has been ignored
# - False: indicates that the last browsed elem has been added to the stack
# When an operator is applied to some elements, they are removed from the ignored_elems stack
# (and replaced by the ignored_elems flag of the operator)
while domain:
next_elem = domain.pop() # Browsing the domain backward simplifies the filtering
if is_leaf(next_elem):
field_name, op, value = next_elem
if field_check(field_name):
field_name = field_name_mapping.get(field_name, field_name)
stack.append((field_name, op, value))
ignored_elems.append(False)
else:
ignored_elems.append(True)
elif next_elem == NOT_OPERATOR:
ignore_operation = ignored_elems.pop()
if not ignore_operation:
stack.append(NOT_OPERATOR)
ignored_elems.append(False)
else:
ignored_elems.append(True)
else: # OR/AND operation
ignore_operand1 = ignored_elems.pop()
ignore_operand2 = ignored_elems.pop()
if not ignore_operand1 and not ignore_operand2:
stack.append(next_elem)
ignored_elems.append(False)
elif ignore_operand1 and ignore_operand2:
ignored_elems.append(True)
else:
ignored_elems.append(False) # the AND/OR operation is replaced by one of its operand which cannot be ignored
return list(reversed(stack))
class Intervals(object):
""" Collection of ordered disjoint intervals with some associated records.
Each interval is a triple ``(start, stop, records)``, where ``records``
is a recordset.
"""
def __init__(self, intervals=()):
self._items = []
if intervals:
# normalize the representation of intervals
append = self._items.append
starts = []
recses = []
for value, flag, recs in sorted(_boundaries(intervals, 'start', 'stop')):
if flag == 'start':
starts.append(value)
recses.append(recs)
else:
start = starts.pop()
if not starts:
append((start, value, recses[0].union(*recses)))
recses.clear()
def __bool__(self):
return bool(self._items)
def __len__(self):
return len(self._items)
def __iter__(self):
return iter(self._items)
def __reversed__(self):
return reversed(self._items)
def __or__(self, other):
""" Return the union of two sets of intervals. """
return Intervals(chain(self._items, other._items))
def __and__(self, other):
""" Return the intersection of two sets of intervals. """
return self._merge(other, False)
def __sub__(self, other):
""" Return the difference of two sets of intervals. """
return self._merge(other, True)
def _merge(self, other, difference):
""" Return the difference or intersection of two sets of intervals. """
result = Intervals()
append = result._items.append
# using 'self' and 'other' below forces normalization
bounds1 = _boundaries(self, 'start', 'stop')
bounds2 = _boundaries(other, 'switch', 'switch')
start = None # set by start/stop
recs1 = None # set by start
enabled = difference # changed by switch
for value, flag, recs in sorted(chain(bounds1, bounds2)):
if flag == 'start':
start = value
recs1 = recs
elif flag == 'stop':
if enabled and start < value:
append((start, value, recs1))
start = None
else:
if not enabled and start is not None:
start = value
if enabled and start is not None and start < value:
append((start, value, recs1))
enabled = not enabled
return result
def sum_intervals(intervals):
""" Sum the intervals duration (unit : hour)"""
return sum(
(stop - start).total_seconds() / 3600
for start, stop, meta in intervals
)
def timezone_datetime(time):
if not time.tzinfo:
time = time.replace(tzinfo=utc)
return time

View File

@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_french_leaves

View File

@@ -1,360 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install_l10n', 'post_install', '-at_install', 'french_leaves')
class TestFrenchLeaves(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
country_fr = cls.env.ref('base.fr')
cls.company = cls.env['res.company'].create({
'name': 'French Company',
'country_id': country_fr.id,
})
cls.employee = cls.env['hr.employee'].create({
'name': 'Camille',
'gender': 'other',
'birthday': '1973-03-29',
'country_id': country_fr.id,
'company_id': cls.company.id,
})
cls.time_off_type = cls.env['hr.leave.type'].create({
'name': 'Time Off',
'requires_allocation': 'no',
'request_unit': 'half_day',
})
cls.company.write({
'l10n_fr_reference_leave_type': cls.time_off_type.id,
})
cls.base_calendar = cls.env['resource.calendar'].create({
'name': 'default calendar',
})
def test_no_differences(self):
# Base case that should not have a different behaviour
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = self.base_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-06',
'request_date_to': '2021-09-10',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
def test_end_of_week(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-06', #monday
'request_date_to': '2021-09-08', #wednesday
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
def test_start_of_week(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-08',
'request_date_to': '2021-09-10',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
def test_last_day_half(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-10', #friday
'request_date_to': '2021-09-10',
'request_unit_half': True,
'request_date_from_period': 'am',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
# Since the employee works on the afternoon, the date_to is not post-poned
self.assertEqual(leave.number_of_days, 0.5, 'The number of days should be equal to 0.5.')
leave.request_date_from_period = 'pm'
# This however should push the date_to
self.assertEqual(leave.number_of_days, 2.5, 'The number of days should be equal to 2.5.')
leave.unlink()
def test_full_time_am_day_half(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-10', #friday
'request_date_to': '2021-09-10',
'request_unit_half': True,
'request_date_from_period': 'am',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
# Since the employee works doesnt work the afternoon, the date_to is post-poned
self.assertEqual(leave.number_of_days, 1, 'The number of days should be equal to 1.')
leave.unlink()
def test_am_day_half(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-24', #friday
'request_date_to': '2021-09-24',
'request_unit_half': True,
'request_date_from_period': 'am',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
# Since the employee works doesnt work the afternoon, the date_to is post-poned
self.assertEqual(leave.number_of_days, 3, 'The number of days should be equal to 3.')
leave.unlink()
def test_calendar_with_holes(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-06',
'request_date_to': '2021-09-10',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
def test_calendar_end_week_hole(self):
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = self.base_calendar
self.employee.resource_calendar_id = employee_calendar
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-06',
'request_date_to': '2021-09-08',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
def test_2_weeks_calendar(self):
company_calendar = self.env['resource.calendar'].create({
'name': 'Company Calendar',
'two_weeks_calendar': True,
'attendance_ids': [
(0, 0, {'week_type': '0', 'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Thursday Morning', 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '0', 'name': 'Friday Morning', 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '0', 'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '1', 'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '1', 'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '1', 'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '1', 'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'week_type': '1', 'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'week_type': '1', 'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
employee_calendar = self.env['resource.calendar'].create({
'name': 'Employee Calendar',
'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Monday Afternoon', 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Tuesday Morning', 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Tuesday Afternoon', 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
(0, 0, {'name': 'Wednesday Morning', 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12, 'day_period': 'morning'}),
(0, 0, {'name': 'Wednesday Afternoon', 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.company.resource_calendar_id = company_calendar
self.employee.resource_calendar_id = employee_calendar
# Week type 0
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-06',
'request_date_to': '2021-09-08',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 5, 'The number of days should be equal to 5.')
leave.unlink()
# Week type 1
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-13',
'request_date_to': '2021-09-15',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 3, 'The number of days should be equal to 3.')
leave.unlink()
# Both ending with week type 1
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-06',
'request_date_to': '2021-09-15',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.')
leave.unlink()
# Both ending with week type 0
leave = self.env['hr.leave'].create({
'name': 'Test',
'holiday_status_id': self.time_off_type.id,
'employee_id': self.employee.id,
'request_date_from': '2021-09-13',
'request_date_to': '2021-09-22',
'company_id': self.company.id,
'resource_calendar_id': self.employee.resource_calendar_id.id,
})
leave._compute_date_from_to()
self.assertEqual(leave.number_of_days, 8, 'The number of days should be equal to 3.')
leave.unlink()

View File

@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.hr</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="70"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div name="work_organization_setting_container" position="after">
<field name="company_country_code" invisible="1"/>
<h2>French Time Off Localization</h2>
<div class="row mt16 o_settings_container" name="work_organization_setting_container">
<div class="col-12 col-lg-6 o_setting_box" id="default_company_work_organization_setting">
<div class="o_setting_right_pane">
<label for="resource_calendar_id"/>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." role="img" aria-label="Values set here are company-specific." groups="base.group_multi_company"/>
<div class="row">
<div class="text-muted col-lg-8">
Set the time off type used as the company Paid Time Off to compute part-timers leave duration
</div>
</div>
<div class="content-group">
<div class="mt16">
<field name="l10n_fr_reference_leave_type" required="1"
class="o_light_label"
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"
context="{'default_company_id': company_id}"/>
</div>
</div>
</div>
</div>
</div>
</div>
</field>
</record>
<menuitem id="hr_holidays_menu_configuration"
name="Settings"
parent="hr_holidays.menu_hr_holidays_configuration"
sequence="10"
action="hr.hr_config_settings_action"
groups="base.group_system"/>
</odoo>