5 Commits

Author SHA1 Message Date
958d0c4118 [FIX]hr_employee_stats_sheet:convert public holidays in employee tz
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m31s
2025-11-05 11:46:47 +01:00
ee1e1cbe65 [FIX]hr_timesheet_sheet_usability: fix issue link in readme
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m31s
2025-10-31 09:58:37 +01:00
004187d9a8 [FIX]hr_employee_stats_sheet:replace allow_negative_leave dependencies to hr_negative_leave module 2025-10-31 09:58:37 +01:00
2e392a5afd [ADD]hr_negative_leave 2025-10-31 09:58:37 +01:00
4d76c78863 [REM]allow_negative_leave_and_allocation_hr_holidays_attendance 2025-10-31 09:58:37 +01:00
11 changed files with 179 additions and 358 deletions

View File

@@ -3,7 +3,7 @@
{
"name": "allow_negative_leave_and_allocation",
"version": "16.0.2.0.0",
"version": "16.0.1.1.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Elabore",
@@ -23,6 +23,11 @@
"views/hr_leave_type_views.xml",
"views/hr_leave_views.xml",
],
"assets": {
'web.assets_backend': [
'allow_negative_leave_and_allocation/static/src/xml/time_off_card.xml',
],
},
# only loaded in demonstration mode
"demo": [],
"js": [],

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-24 07:50+0000\n"
"PO-Revision-Date: 2025-10-24 07:50+0000\n"
"POT-Creation-Date: 2025-01-02 12:11+0000\n"
"PO-Revision-Date: 2025-01-02 12:11+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -15,30 +15,6 @@ msgstr ""
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: allow_negative_leave_and_allocation
#. odoo-python
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#, python-format
msgid " days"
msgstr ""
#. module: allow_negative_leave_and_allocation
#. odoo-python
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#, python-format
msgid " hours"
msgstr ""
#. module: allow_negative_leave_and_allocation
#. odoo-python
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#, python-format
msgid "%g remaining out of %g"
msgstr ""
#. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative
msgid "Allow Negative Leaves"
@@ -57,15 +33,8 @@ msgid ""
msgstr ""
#. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__remaining_leaves_allowing_negative
msgid "Remaining Leaves when Negative Allowed"
msgstr ""
#. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave__smart_search
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_allocation__smart_search
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__smart_search
msgid "Smart Search"
#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_duration_check
msgid "The duration must be greater than 0."
msgstr ""
#. module: allow_negative_leave_and_allocation

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-24 07:49+0000\n"
"PO-Revision-Date: 2025-10-24 07:49+0000\n"
"POT-Creation-Date: 2025-01-02 12:11+0000\n"
"PO-Revision-Date: 2025-01-02 12:11+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -15,30 +15,6 @@ msgstr ""
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: allow_negative_leave_and_allocation
#. odoo-python
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#, python-format
msgid " days"
msgstr "jours"
#. module: allow_negative_leave_and_allocation
#. odoo-python
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#, python-format
msgid " hours"
msgstr "heures"
#. module: allow_negative_leave_and_allocation
#. odoo-python
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#: code:addons/allow_negative_leave_and_allocation/models/hr_leave_type.py:0
#, python-format
msgid "%g remaining out of %g"
msgstr "%g restant sur %g"
#. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative
msgid "Allow Negative Leaves"
@@ -57,15 +33,8 @@ msgid ""
msgstr ""
#. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__remaining_leaves_allowing_negative
msgid "Remaining Leaves when Negative Allowed"
msgstr ""
#. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave__smart_search
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_allocation__smart_search
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__smart_search
msgid "Smart Search"
#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_duration_check
msgid "The duration must be greater than 0."
msgstr ""
#. module: allow_negative_leave_and_allocation

View File

@@ -1 +1 @@
from . import hr_leave_type, hr_leave
from . import hr_leave_type, hr_leave, hr_leave_allocation

View File

@@ -10,7 +10,7 @@ class HrLeave(models.Model):
@api.constrains('state', 'number_of_days', 'holiday_status_id')
def _check_holidays(self):
# Keep only leaves that do not allow negative balances
# On ne garde que les congés qui ne permettent pas le négatif
to_check = self.filtered(lambda h: not h.holiday_status_id.allows_negative)
if to_check:
super(HrLeave, to_check)._check_holidays()

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# Copyright (c) 2005-2006 Axelor SARL. (http://www.axelor.com)
from odoo import models
class HrLeaveAllocation(models.Model):
_inherit = "hr.leave.allocation"
_sql_constraints = [
('type_value',
"CHECK( (holiday_type='employee' AND (employee_id IS NOT NULL OR multi_employee IS TRUE)) or "
"(holiday_type='category' AND category_id IS NOT NULL) or "
"(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."),
]

View File

@@ -1,13 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from collections import defaultdict
from datetime import time, timedelta
from odoo import api, fields, models
from odoo.tools.translate import _
from odoo.addons.resource.models.resource import Intervals
class HolidaysType(models.Model):
_inherit = "hr.leave.type"
@@ -16,23 +12,6 @@ class HolidaysType(models.Model):
allows_negative = fields.Boolean(string='Allow Negative Leaves',
help="If checked, users request can exceed the allocated days and balance can go in negative.")
remaining_leaves_allowing_negative = fields.Float(
string="Remaining Leaves when Negative Allowed",
compute='_compute_remaining_leaves_allowing_negative',
)
def _compute_remaining_leaves_allowing_negative(self):
for holiday_type in self:
if holiday_type.allows_negative:
# if left != usable : remaining_leaves_allowing_negative = left + usable
if holiday_type.virtual_remaining_leaves < 0 (holiday_type.max_leaves - holiday_type.virtual_leaves_taken) != holiday_type.virtual_remaining_leaves:
holiday_type.remaining_leaves_allowing_negative = holiday_type.max_leaves - holiday_type.virtual_leaves_taken + holiday_type.virtual_remaining_leaves
else:
# else : remaining_leaves_allowing_negative = left as usual
holiday_type.remaining_leaves_allowing_negative = holiday_type.max_leaves - holiday_type.virtual_leaves_taken
else:
holiday_type.remaining_leaves_allowing_negative = None
@api.depends('requires_allocation')
def _compute_valid(self):
res = super()._compute_valid()
@@ -40,203 +19,7 @@ class HolidaysType(models.Model):
if not holiday_type.has_valid_allocation:
holiday_type.has_valid_allocation = holiday_type.allows_negative
#overwrite _get_employees_days_per_allocation() from hr_holidays module
def _get_employees_days_per_allocation(self, employee_ids, date=None):
if not date:
date = fields.Date.to_date(self.env.context.get('default_date_from')) or fields.Date.context_today(self)
leaves_domain = [
('employee_id', 'in', employee_ids),
('state', 'in', ['confirm', 'validate1', 'validate']),
('holiday_status_id', 'in', self.ids)
]
if self.env.context.get("ignore_future"):
leaves_domain.append(('date_from', '<=', date))
leaves = self.env['hr.leave'].search(leaves_domain)
allocations = self.env['hr.leave.allocation'].with_context(active_test=False).search([
('employee_id', 'in', employee_ids),
('state', 'in', ['validate']),
('holiday_status_id', 'in', self.ids),
])
# The allocation_employees dictionary groups the allocations based on the employee and the holiday type
# The structure is the following:
# - KEYS:
# allocation_employees
# |--employee_id
# |--holiday_status_id
# - VALUES:
# Intervals with the start and end date of each allocation and associated allocations within this interval
allocation_employees = defaultdict(lambda: defaultdict(list))
### Creation of the allocation intervals ###
for holiday_status_id in allocations.holiday_status_id:
for employee_id in employee_ids:
allocation_intervals = Intervals([(
fields.datetime.combine(allocation.date_from, time.min),
fields.datetime.combine(allocation.date_to or datetime.date.max, time.max),
allocation)
for allocation in allocations.filtered(lambda allocation: allocation.employee_id.id == employee_id and allocation.holiday_status_id == holiday_status_id)])
allocation_employees[employee_id][holiday_status_id] = allocation_intervals
# The leave_employees dictionary groups the leavess based on the employee and the holiday type
# The structure is the following:
# - KEYS:
# leave_employees
# |--employee_id
# |--holiday_status_id
# - VALUES:
# Intervals with the start and end date of each leave and associated leave within this interval
leaves_employees = defaultdict(lambda: defaultdict(list))
leave_intervals = []
### Creation of the leave intervals ###
if leaves:
for holiday_status_id in leaves.holiday_status_id:
for employee_id in employee_ids:
leave_intervals = Intervals([(
fields.datetime.combine(leave.date_from, time.min),
fields.datetime.combine(leave.date_to, time.max),
leave)
for leave in leaves.filtered(lambda leave: leave.employee_id.id == employee_id and leave.holiday_status_id == holiday_status_id)])
leaves_employees[employee_id][holiday_status_id] = leave_intervals
# allocation_days_consumed is a dictionary to map the number of days/hours of leaves taken per allocation
# The structure is the following:
# - KEYS:
# allocation_days_consumed
# |--employee_id
# |--holiday_status_id
# |--allocation
# |--virtual_leaves_taken
# |--leaves_taken
# |--virtual_remaining_leaves
# |--remaining_leaves
# |--max_leaves
# |--closest_allocation_to_expire
# - VALUES:
# Integer representing the number of (virtual) remaining leaves, (virtual) leaves taken or max leaves for each allocation.
# The unit is in hour or days depending on the leave type request unit
allocations_days_consumed = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: 0))))
company_domain = [('company_id', 'in', list(set(self.env.company.ids + self.env.context.get('allowed_company_ids', []))))]
### Existing leaves assigned to allocations ###
if leaves_employees:
for employee_id, leaves_interval_by_status in leaves_employees.items():
for holiday_status_id in leaves_interval_by_status:
days_consumed = allocations_days_consumed[employee_id][holiday_status_id]
if allocation_employees[employee_id][holiday_status_id]:
allocations = allocation_employees[employee_id][holiday_status_id] & leaves_interval_by_status[holiday_status_id]
available_allocations = self.env['hr.leave.allocation']
for allocation_interval in allocations._items:
available_allocations |= allocation_interval[2]
# Consume the allocations that are close to expiration first
sorted_available_allocations = available_allocations.filtered('date_to').sorted(key='date_to')
sorted_available_allocations += available_allocations.filtered(lambda allocation: not allocation.date_to)
leave_intervals = leaves_interval_by_status[holiday_status_id]._items
sorted_allocations_with_remaining_leaves = self.env['hr.leave.allocation']
for leave_interval in leave_intervals:
leaves = leave_interval[2]
for leave in leaves:
if leave.leave_type_request_unit in ['day', 'half_day']:
leave_duration = leave.number_of_days
leave_unit = 'days'
else:
leave_duration = leave.number_of_hours_display
leave_unit = 'hours'
if holiday_status_id.requires_allocation != 'no':
for available_allocation in sorted_available_allocations:
# if the allocation is not valid for the leave period, continue
if (available_allocation.date_to and available_allocation.date_to < leave.date_from.date()) \
or (available_allocation.date_from > leave.date_to.date()):
continue
# calculate the number of days/hours for this allocation (allocation days/hours - leaves already taken)
virtual_remaining_leaves = (available_allocation.number_of_days if leave_unit == 'days' else available_allocation.number_of_hours_display) - allocations_days_consumed[employee_id][holiday_status_id][available_allocation]['virtual_leaves_taken']
###########################################
# Modification for leaves allowing negative #
###########################################
# if negative is allowed for this leave type, we can exceed the number of available days in this allocation
if holiday_status_id.allows_negative:
max_leaves = leave_duration
else:
# if negative is not allowed for this leave type, then we cannot exceed the allocation amount
# the max leaves for this allocation is the minimum between the remaining available days and the leave duration
max_leaves = min(virtual_remaining_leaves, leave_duration)
# the new calculation of days taken for this allocation is previous taken + max_leaves (which can never exceed the allocation total)
days_consumed[available_allocation]['virtual_leaves_taken'] += max_leaves
if leave.state == 'validate':
days_consumed[available_allocation]['leaves_taken'] += max_leaves
leave_duration -= max_leaves
# Check valid allocations with still availabe leaves on it
if days_consumed[available_allocation]['virtual_remaining_leaves'] > 0 and available_allocation.date_to and available_allocation.date_to > date:
sorted_allocations_with_remaining_leaves |= available_allocation
if leave_duration > 0:
# There are not enough allocation for the number of leaves
days_consumed[False]['virtual_remaining_leaves'] -= leave_duration
else:
days_consumed[False]['virtual_leaves_taken'] += leave_duration
if leave.state == 'validate':
days_consumed[False]['leaves_taken'] += leave_duration
# no need to sort the allocations again
allocations_days_consumed[employee_id][holiday_status_id][False]['closest_allocation_to_expire'] = sorted_allocations_with_remaining_leaves[0] if sorted_allocations_with_remaining_leaves else False
# Future available leaves
future_allocations_date_from = fields.datetime.combine(date, time.min)
future_allocations_date_to = fields.datetime.combine(date, time.max) + timedelta(days=5*365)
for employee_id, allocation_intervals_by_status in allocation_employees.items():
employee = self.env['hr.employee'].browse(employee_id)
for holiday_status_id, intervals in allocation_intervals_by_status.items():
if not intervals:
continue
future_allocation_intervals = intervals & Intervals([(
future_allocations_date_from,
future_allocations_date_to,
self.env['hr.leave'])])
search_date = date
closest_allocations = self.env['hr.leave.allocation']
for interval in intervals._items:
closest_allocations |= interval[2]
allocations_with_remaining_leaves = self.env['hr.leave.allocation']
for interval_from, interval_to, interval_allocations in future_allocation_intervals._items:
if interval_from.date() > search_date:
continue
interval_allocations = interval_allocations.filtered('active')
if not interval_allocations:
continue
# If no end date to the allocation, consider the number of days remaining as infinite
employee_quantity_available = (
employee._get_work_days_data_batch(interval_from, interval_to, compute_leaves=False, domain=company_domain)[employee_id]
if interval_to != future_allocations_date_to
else {'days': float('inf'), 'hours': float('inf')}
)
reached_remaining_days_limit = False
for allocation in interval_allocations:
if allocation.date_from > search_date:
continue
days_consumed = allocations_days_consumed[employee_id][holiday_status_id][allocation]
if allocation.type_request_unit in ['day', 'half_day']:
quantity_available = employee_quantity_available['days']
remaining_days_allocation = (allocation.number_of_days - days_consumed['virtual_leaves_taken'])
else:
quantity_available = employee_quantity_available['hours']
remaining_days_allocation = (allocation.number_of_hours_display - days_consumed['virtual_leaves_taken'])
#TODO leave allocation allowing negative not yet handled here
if quantity_available <= remaining_days_allocation:
search_date = interval_to.date() + timedelta(days=1)
days_consumed['max_leaves'] = allocation.number_of_days if allocation.type_request_unit in ['day', 'half_day'] else allocation.number_of_hours_display
if not reached_remaining_days_limit:
days_consumed['virtual_remaining_leaves'] += min(quantity_available, remaining_days_allocation)
days_consumed['remaining_leaves'] = days_consumed['max_leaves'] - days_consumed['leaves_taken']
if remaining_days_allocation >= quantity_available:
reached_remaining_days_limit = True
# Check valid allocations with still availabe leaves on it
if days_consumed['virtual_remaining_leaves'] > 0 and allocation.date_to and allocation.date_to > date:
allocations_with_remaining_leaves |= allocation
allocations_sorted = sorted(allocations_with_remaining_leaves, key=lambda a: a.date_to)
allocations_days_consumed[employee_id][holiday_status_id][False]['closest_allocation_to_expire'] = allocations_sorted[0] if allocations_sorted else False
return allocations_days_consumed
def _get_days_request(self):
res = super()._get_days_request()
res[1]['allows_negative'] = self.allows_negative
return res

View File

@@ -0,0 +1,9 @@
<template>
<t t-name="allow_negative_leave_and_allocation.TimeOffCard" t-inherit="hr_holidays.TimeOffCard" t-inherit-mode="extension" owl="1">
<xpath expr="//t[@t-set='duration']" position="replace">
<t t-set="duration" t-value="props.requires_allocation
? (props.data['allows_negative'] ? data.usable_remaining_leaves : data.virtual_remaining_leaves)
: data.virtual_leaves_taken" />
</xpath>
</t>
</template>

View File

@@ -1,7 +1,9 @@
import logging
import pytz
from odoo import api, fields, models
from datetime import timedelta
from pytz import utc
_logger = logging.getLogger(__name__)
@@ -162,7 +164,40 @@ class HrEmployeeStats(models.Model):
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)))
stat.is_public_holiday = stat._is_public_holiday_accordig_to_employe_tz()
def _convert_to_employee_tz(self, date):
"""Convert a UTC datetime to the employee's timezone datetime."""
self.ensure_one()
if not date:
return None
employee_tz = pytz.timezone(self.employee_id.tz or "UTC")
if date.tzinfo is None:
dt = pytz.utc.localize(date)
return dt.astimezone(employee_tz)
def _is_public_holiday_accordig_to_employe_tz(self):
self.ensure_one()
if not self.date or not self.employee_id:
return False
#get public holidays for the employee
public_holidays = self.employee_id._get_public_holidays(
self.date, self.date
)
if not public_holidays:
return False
ph = public_holidays[0]
# Convert public holiday to the employee timezone
ph_datetime_from_tz = self._convert_to_employee_tz(ph.date_from)
ph_datetime_to_tz = self._convert_to_employee_tz(ph.date_to)
# Convert datetime to date
ph_date_from = ph_datetime_from_tz.date()
ph_date_to = ph_datetime_to_tz.date()
# Check if the stat date falls within the public holiday range after conversion in employee tz
if ph_date_from <= self.date <= ph_date_to:
return True
else:
return False
def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours):
self.ensure_one()

View File

@@ -110,8 +110,8 @@ class HrTimesheetSheet(models.Model):
("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
("date_end", "=", False), # no end date OR
("date_end", ">=", self.date_start), # end date after start
],
)
return contracts

View File

@@ -1,7 +1,9 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from odoo.exceptions import UserError
from odoo.fields import Date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestHrEmployeeStatsRecovery(TransactionCase):
@@ -19,6 +21,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
self.employee = self.env['hr.employee'].create({
'name': 'Camille',
'user_id': self.user.id,
'tz': 'Europe/Paris',
})
self.base_calendar = self.env['resource.calendar'].create({
'name': 'Default Calendar',
@@ -35,11 +38,13 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
],
})
self.base_calendar.hours_per_day = 7
self.employee.resource_calendar_id = self.base_calendar
self.env.company.recovery_type_id = self.recovery_type
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
# Create a timesheet for the week (Monday to Sunday)
return self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': start_date,
@@ -47,7 +52,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
})
def _create_stats(self, start_date, nb_days, unit_amount):
# Crée des temps du lundi au vendredi (ou nb_days)
# Create timesheet lines from Monday to Friday (or nb_days)
for i in range(nb_days):
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
@@ -56,16 +61,19 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
'account_id': 1,
'name': 'Work Entry',
})
# Génère les hr_employee_stats pour chaque jour de la période
# Generate hr.employee.stats for each day of the period
stat = self.env['hr.employee.stats'].create({
'employee_id': self.employee.id,
'date': start_date + timedelta(days=i),
})
stat._compute_dayofweek()
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
start_date = date.today() - timedelta(
days=date.today().weekday() + 7
) # monday of last week
self.recovery_type.request_unit = 'day'
self.recovery_type.allows_negative = False
timesheet_sheet = self._create_timesheet_sheet(start_date)
@@ -74,56 +82,58 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
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
for stat in self._create_stats(start_date, 5, 7): # create 5 stats of 7h each
# Compare calculated recovery hours and the calendar which plans 7h per day
self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # the employee worked 7h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # no difference between worked and planned hours
# The timesheet for this period should count 0 gap hours and 0 recovery hours
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
start_date = date.today() - timedelta(
days=date.today().weekday() + 7
) # monday of last week
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%
for stat in self._create_stats(start_date, 5, 8): # create 5 stats of 8h each
# Compare calculated recovery hours and the calendar which plans 7h per day
self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # the employee worked 8h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned
# The timesheet should count 5 overtime hours = 6.25h recovery with 25% coefficient
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
# generate the recovery allocation from the timesheet
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")
self.assertEqual(recovery_allocation.number_of_days,0.8928571428571429, "The recovery allocation should be for 6.25h/7h = 0.8928571428571429 day")
def test_negative_recovery_hours(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
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
for stat in self._create_stats(start_date, 5, 6): # create 5 stats of 6h each
# Compare calculated recovery hours and the calendar which plans 7h per day
self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # the employee worked 6h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # the employee worked one hour less than planned
# The timesheet should count -5 gap hours and -5 recovery hours
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, -5, "timesheet_sheet_gap_hours should be -5",) # total gap hours for the week
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, -5, "timesheet_sheet_recovery_hours should be -5",) # -5h will be the recovery allocation (no coef applied for deficits)
# generate the recovery allocation from the timesheet
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 = ")
self.assertEqual(recovery_allocation.number_of_days, -0.7142857142857143, "The recovery allocation should be for -5/7 hours = 0.7142857142857143 days")
def test_recovery_hours_part_time_employee(self):
part_time_calendar = self.env['resource.calendar'].create({
@@ -140,19 +150,18 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
],
})
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
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
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)
for stat in self._create_stats(start_date, 4, 8): # create 4 stats of 8h each
# Compare calculated recovery hours and the calendar which plans 7h per day for 4 days
self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # the employee worked 8h each day
self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day
self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day
self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day
self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned
# The timesheet should count 4 overtime hours = 5 recovery hours with 25% coefficient
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) # total extra hours for the week
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h will be the recovery allocation (25% coef)
def test_recovery_hours_change_contract(self):
part_time_calendar = self.env['resource.calendar'].create({
@@ -172,8 +181,8 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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
'date_start': date.today() - timedelta(days=300), # fake start date
'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # end date: wednesday of last week
'resource_calendar_id': self.base_calendar.id,
'wage': 2000,
'state': 'close',
@@ -182,16 +191,16 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
'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_start': date.today() - timedelta(days= date.today().weekday() + 4), # start date: thursday of last week
'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
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
# 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
# the creation of the recovery allocation should raise an error
with self.assertRaises(UserError):
timesheet_sheet.action_generate_recovery_allocation()
@@ -242,9 +251,33 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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")
def test_public_holiday(self):
# create a public holiday
self.env["resource.calendar.leaves"].create(
{
"name": "1 mai 2025",
"date_from": datetime(2025,4,30,22,0,0),
"date_to": datetime(2025,5,1,21,0,0),
}
)
#create 5 stats of 7h each including the public holiday on 1st may
stats = self._create_stats(Date.to_date("2025-04-28"), 5, 7)
for stat in stats:
stat._compute_dayofweek()
stat._compute_hours()
#create 1 timesheet sheet from monday to friday including the public holiday on 1st may
timesheet_sheet = self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': "2025-04-28",
'date_end': "2025-05-04",
})
self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 7, "timesheet_sheet_gap_hours should be 7",)
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 8.75, "timesheet_sheet_recovery_hours should be 8,75",)
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, "Il doit y avoir une allocation de récupération générée")
self.assertEqual(recovery_allocation.number_of_days,1.25, "The recovery allocation should be 1,25 days")