7 Commits

24 changed files with 482 additions and 460 deletions

View File

@@ -3,7 +3,7 @@
{ {
"name": "allow_negative_leave_and_allocation", "name": "allow_negative_leave_and_allocation",
"version": "16.0.1.1.0", "version": "16.0.2.0.0",
"author": "Elabore", "author": "Elabore",
"website": "https://elabore.coop", "website": "https://elabore.coop",
"maintainer": "Elabore", "maintainer": "Elabore",
@@ -23,11 +23,6 @@
"views/hr_leave_type_views.xml", "views/hr_leave_type_views.xml",
"views/hr_leave_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 # only loaded in demonstration mode
"demo": [], "demo": [],
"js": [], "js": [],

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Odoo Server 16.0\n" "Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-02 12:11+0000\n" "POT-Creation-Date: 2025-10-24 07:50+0000\n"
"PO-Revision-Date: 2025-01-02 12:11+0000\n" "PO-Revision-Date: 2025-10-24 07:50+0000\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -15,6 +15,30 @@ msgstr ""
"Content-Transfer-Encoding: \n" "Content-Transfer-Encoding: \n"
"Plural-Forms: \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 #. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative #: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative
msgid "Allow Negative Leaves" msgid "Allow Negative Leaves"
@@ -33,8 +57,15 @@ msgid ""
msgstr "" msgstr ""
#. module: allow_negative_leave_and_allocation #. module: allow_negative_leave_and_allocation
#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_duration_check #: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__remaining_leaves_allowing_negative
msgid "The duration must be greater than 0." 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"
msgstr "" msgstr ""
#. module: allow_negative_leave_and_allocation #. module: allow_negative_leave_and_allocation

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Odoo Server 16.0\n" "Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-02 12:11+0000\n" "POT-Creation-Date: 2025-10-24 07:49+0000\n"
"PO-Revision-Date: 2025-01-02 12:11+0000\n" "PO-Revision-Date: 2025-10-24 07:49+0000\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -15,6 +15,30 @@ msgstr ""
"Content-Transfer-Encoding: \n" "Content-Transfer-Encoding: \n"
"Plural-Forms: \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 #. module: allow_negative_leave_and_allocation
#: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative #: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__allows_negative
msgid "Allow Negative Leaves" msgid "Allow Negative Leaves"
@@ -33,8 +57,15 @@ msgid ""
msgstr "" msgstr ""
#. module: allow_negative_leave_and_allocation #. module: allow_negative_leave_and_allocation
#: model:ir.model.constraint,message:allow_negative_leave_and_allocation.constraint_hr_leave_allocation_duration_check #: model:ir.model.fields,field_description:allow_negative_leave_and_allocation.field_hr_leave_type__remaining_leaves_allowing_negative
msgid "The duration must be greater than 0." 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"
msgstr "" msgstr ""
#. module: allow_negative_leave_and_allocation #. module: allow_negative_leave_and_allocation

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
# -*- 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,9 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details. # 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 import api, fields, models
from odoo.tools.translate import _
from odoo.addons.resource.models.resource import Intervals
class HolidaysType(models.Model): class HolidaysType(models.Model):
_inherit = "hr.leave.type" _inherit = "hr.leave.type"
@@ -12,6 +16,23 @@ class HolidaysType(models.Model):
allows_negative = fields.Boolean(string='Allow Negative Leaves', allows_negative = fields.Boolean(string='Allow Negative Leaves',
help="If checked, users request can exceed the allocated days and balance can go in negative.") 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') @api.depends('requires_allocation')
def _compute_valid(self): def _compute_valid(self):
res = super()._compute_valid() res = super()._compute_valid()
@@ -19,7 +40,203 @@ class HolidaysType(models.Model):
if not holiday_type.has_valid_allocation: if not holiday_type.has_valid_allocation:
holiday_type.has_valid_allocation = holiday_type.allows_negative holiday_type.has_valid_allocation = holiday_type.allows_negative
def _get_days_request(self): #overwrite _get_employees_days_per_allocation() from hr_holidays module
res = super()._get_days_request() def _get_employees_days_per_allocation(self, employee_ids, date=None):
res[1]['allows_negative'] = self.allows_negative if not date:
return res 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

View File

@@ -1,9 +0,0 @@
<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,6 +1,6 @@
{ {
"name": "hr_employee_stats_sheet", "name": "hr_employee_stats_sheet",
"version": "16.0.3.1.3", "version": "16.0.2.1.0",
"description": "Add global sheet for employee stats", "description": "Add global sheet for employee stats",
"summary": "Add global sheet for employee stats", "summary": "Add global sheet for employee stats",
"author": "Nicolas JEUDY", "author": "Nicolas JEUDY",
@@ -15,8 +15,6 @@
"hr_timesheet", "hr_timesheet",
"hr_timesheet_sheet", "hr_timesheet_sheet",
"resource", "resource",
"hr_employee_calendar_planning",
"hr_timesheet_sheet_usability_misc",
], ],
"data": [ "data": [
"security/ir.model.access.csv", "security/ir.model.access.csv",

View File

@@ -2,5 +2,4 @@ from . import hr_employee_stats
from . import hr_timesheet_sheet from . import hr_timesheet_sheet
from . import res_config from . import res_config
from . import res_company from . import res_company
from . import hr_leave_allocation from . import hr_leave_allocation
from . import hr_employee

View File

@@ -1,38 +0,0 @@
import pytz
from odoo import models, _
from datetime import timedelta
from pytz import utc
from odoo.exceptions import UserError
class HrEmployee(models.Model):
_inherit = "hr.employee"
def _get_calendar_in_progress_during_a_time_period(self, date_start, date_end):
"""
get the ressource calendar which was used during the timesheet sheet time period
"""
self.ensure_one()
# find calendar(s) running over the duration of the timesheet
calendars = self.env["hr.employee.calendar"].search(
[
("employee_id", "=", self.id),
("date_start", "<=", date_end),
"|",
("date_end", "=", False), # pas de date de fin OU
("date_end", ">=", date_start), # date de fin après le début
],
)
if len(calendars) > 1:
raise UserError(
_("There is a calendar starting during the timesheet sheet time period for the employee %s "
"Please create a new timesheet sheet starting from the new calendar start date")
% self.display_name
)
# if hr_employee_calendar found, use its calendar_id
elif calendars and calendars[0].calendar_id:
return calendars[0].calendar_id
# if resource calendar not found, use the ressource calendar of the company linked to the employee
elif self.company_id.resource_calendar_id:
return self.company_id.resource_calendar_id
return None

View File

@@ -1,10 +1,9 @@
import logging import logging
import pytz
from odoo import api, fields, models, _ from odoo import api, fields, models
from datetime import timedelta from datetime import timedelta
from pytz import utc
from odoo.exceptions import UserError _logger = logging.getLogger(__name__)
class HrEmployeeStats(models.Model): class HrEmployeeStats(models.Model):
@@ -18,10 +17,11 @@ class HrEmployeeStats(models.Model):
is_public_holiday = fields.Boolean("Public Holiday", compute="_compute_dayofweek") is_public_holiday = fields.Boolean("Public Holiday", compute="_compute_dayofweek")
employee_id = fields.Many2one("hr.employee", "Employee", required=True) employee_id = fields.Many2one("hr.employee", "Employee", required=True)
department_id = fields.Many2one("hr.department", "Department") department_id = fields.Many2one("hr.department", "Department")
timesheet_line_ids = fields.Many2many( timesheet_line_ids = fields.One2many(
"account.analytic.line", "account.analytic.line",
"employee_id",
"Timesheet lines",
compute="_compute_timesheet_line_ids", compute="_compute_timesheet_line_ids",
string="Timesheet lines",
) )
date = fields.Date("Date", required=True) date = fields.Date("Date", required=True)
company_id = fields.Many2one( company_id = fields.Many2one(
@@ -86,7 +86,7 @@ class HrEmployeeStats(models.Model):
total_planned_hours = 0 total_planned_hours = 0
if self.employee_id and self.date and not self.is_public_holiday: if self.employee_id and self.date and not self.is_public_holiday:
dayofweek = int(self.date.strftime("%u")) - 1 dayofweek = int(self.date.strftime("%u")) - 1
calendar_id = self.employee_id._get_calendar_in_progress_during_a_time_period(self.date,self.date) calendar_id = self.employee_id.resource_calendar_id
week_number = self.date.isocalendar()[1] % 2 week_number = self.date.isocalendar()[1] % 2
if calendar_id.two_weeks_calendar: if calendar_id.two_weeks_calendar:
hours = calendar_id.attendance_ids.search( hours = calendar_id.attendance_ids.search(
@@ -119,17 +119,11 @@ class HrEmployeeStats(models.Model):
("holiday_status_id", "=", self._get_holiday_status_id()), ("holiday_status_id", "=", self._get_holiday_status_id()),
("request_date_from", "<=", self.date), ("request_date_from", "<=", self.date),
("request_date_to", ">=", self.date), ("request_date_to", ">=", self.date),
], ]
)
total_recovery_hours = sum(
recovery_ids.mapped("number_of_hours_display")
) )
if recovery_ids:
for recovery_id in recovery_ids:
if recovery_id.request_unit_hours:
recovery_hours = recovery_id.number_of_hours_display
total_recovery_hours += min(recovery_hours,self._get_total_planned_hours())
elif recovery_id.request_unit_half:
total_recovery_hours += self._get_total_planned_hours() / 2
else :
total_recovery_hours += self._get_total_planned_hours()
return total_recovery_hours return total_recovery_hours
def _get_total_leave_hours(self): def _get_total_leave_hours(self):
@@ -137,23 +131,22 @@ class HrEmployeeStats(models.Model):
leave = self.env["hr.leave"] leave = self.env["hr.leave"]
total_leave_hours = 0 total_leave_hours = 0
if self.date and self.employee_id: if self.date and self.employee_id:
leave_ids = leave.search( leave_id = leave.search(
[ [
("employee_id", "=", self.employee_id.id), ("employee_id", "=", self.employee_id.id),
("holiday_status_id", "!=", self._get_holiday_status_id()), ("holiday_status_id", "!=", self._get_holiday_status_id()),
("request_date_from", "<=", self.date), ("request_date_from", "<=", self.date),
("request_date_to", ">=", self.date), ("request_date_to", ">=", self.date),
], ],
limit=1
) )
if leave_ids: if leave_id:
for leave_id in leave_ids: if leave_id.request_unit_hours:
if leave_id.request_unit_hours: total_leave_hours = leave_id.number_of_hours_display
leave_hours = leave_id.number_of_hours_display elif leave_id.request_unit_half:
total_leave_hours += min(leave_hours,self._get_total_planned_hours()) total_leave_hours = self._get_total_planned_hours() / 2
elif leave_id.request_unit_half: else :
total_leave_hours += self._get_total_planned_hours() / 2 total_leave_hours = self._get_total_planned_hours()
else :
total_leave_hours += self._get_total_planned_hours()
return total_leave_hours return total_leave_hours
@api.depends("employee_id", "date") @api.depends("employee_id", "date")
@@ -169,44 +162,8 @@ class HrEmployeeStats(models.Model):
stat.is_public_holiday = False stat.is_public_holiday = False
continue continue
stat.dayofweek = int(stat.date.strftime("%u")) - 1 stat.dayofweek = int(stat.date.strftime("%u")) - 1
stat.is_public_holiday = stat._is_public_holiday_accordig_to_employe_tz() stat.is_public_holiday = bool(stat.sheet_id.employee_id._get_public_holidays(stat.date, stat.date - timedelta(days=1)))
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
if len(public_holidays) > 1:
raise UserError(
_("Several holidays have been found ont he date '%s'. Please correct the anomaly before continuing.") % self.date
)
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): def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours):
self.ensure_one() self.ensure_one()
balance = ( balance = (

View File

@@ -100,6 +100,65 @@ class HrTimesheetSheet(models.Model):
sheet.recovery_allocation_ids.write({"state": "refuse"}) sheet.recovery_allocation_ids.write({"state": "refuse"})
return res 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
#get the ressource calendar linked to the employee
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): def _get_working_hours_per_day(self):
""" """
Get the hours per day for the employee according to: Get the hours per day for the employee according to:
@@ -110,11 +169,17 @@ class HrTimesheetSheet(models.Model):
:return: hours per day :return: hours per day
""" """
# get ressource calendar id used during the timesheet sheet time period # get ressource calendar id used during the timesheet sheet time period
ressource_calendar_id = self.employee_id._get_calendar_in_progress_during_a_time_period(self.date_start,self.date_end) ressource_calendar_id = self._get_calendar_in_progress_during_timesheet_time_period()
if ressource_calendar_id: if ressource_calendar_id:
return ressource_calendar_id.hours_per_day return ressource_calendar_id.hours_per_day
return 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): def action_generate_recovery_allocation(self):
# check if the user has the right to review the timesheet sheet # check if the user has the right to review the timesheet sheet
self.ensure_one() self.ensure_one()
@@ -147,6 +212,25 @@ class HrTimesheetSheet(models.Model):
# get recovery hours from total gap hours of the timesheet sheet # get recovery hours from total gap hours of the timesheet sheet
recovery_hours = self._get_timesheet_sheet_recovery_hours() 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 # convert recovery hours into days
recovery_days = recovery_hours / self._get_working_hours_per_day() recovery_days = recovery_hours / self._get_working_hours_per_day()
@@ -159,7 +243,6 @@ class HrTimesheetSheet(models.Model):
"number_of_days": recovery_days, "number_of_days": recovery_days,
"timesheet_sheet_id": self.id, "timesheet_sheet_id": self.id,
"allocation_type": 'accrual', "allocation_type": 'accrual',
"date_from": self.date_start,
} }
) )

View File

@@ -1,8 +1,7 @@
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from odoo.tests import tagged from odoo.tests import tagged
from datetime import date, timedelta, datetime from datetime import date, timedelta
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.fields import Date
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestHrEmployeeStatsRecovery(TransactionCase): class TestHrEmployeeStatsRecovery(TransactionCase):
@@ -20,7 +19,6 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
self.employee = self.env['hr.employee'].create({ self.employee = self.env['hr.employee'].create({
'name': 'Camille', 'name': 'Camille',
'user_id': self.user.id, 'user_id': self.user.id,
'tz': 'Europe/Paris',
}) })
self.base_calendar = self.env['resource.calendar'].create({ self.base_calendar = self.env['resource.calendar'].create({
'name': 'Default Calendar', 'name': 'Default Calendar',
@@ -37,13 +35,11 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (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.employee.resource_calendar_id = self.base_calendar
self.env.company.recovery_type_id = self.recovery_type
self.env.company.coef = 25 self.env.company.coef = 25
def _create_timesheet_sheet(self, start_date): def _create_timesheet_sheet(self, start_date):
# Create a timesheet for the week (Monday to Sunday) #Crée une feuille de temps pour la semaine du lundi au dimanche
return self.env['hr_timesheet.sheet'].create({ return self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': start_date, 'date_start': start_date,
@@ -51,7 +47,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
}) })
def _create_stats(self, start_date, nb_days, unit_amount): def _create_stats(self, start_date, nb_days, unit_amount):
# Create timesheet lines from Monday to Friday (or nb_days) # Crée des temps du lundi au vendredi (ou nb_days)
for i in range(nb_days): for i in range(nb_days):
self.env['account.analytic.line'].create({ self.env['account.analytic.line'].create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
@@ -60,17 +56,16 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
'account_id': 1, 'account_id': 1,
'name': 'Work Entry', 'name': 'Work Entry',
}) })
# Generate hr.employee.stats for each day of the period # Génère les hr_employee_stats pour chaque jour de la période
stat = self.env['hr.employee.stats'].create({ stat = self.env['hr.employee.stats'].create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date': start_date + timedelta(days=i), 'date': start_date + timedelta(days=i),
}) })
stat._compute_dayofweek()
stat._compute_hours() stat._compute_hours()
yield stat yield stat
def test_invalide_recovery_type(self): def test_invalide_recovery_type(self):
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week 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.request_unit = 'day'
self.recovery_type.allows_negative = False self.recovery_type.allows_negative = False
timesheet_sheet = self._create_timesheet_sheet(start_date) timesheet_sheet = self._create_timesheet_sheet(start_date)
@@ -79,13 +74,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
def test_no_recovery_hours(self): def test_no_recovery_hours(self):
self.env['hr.employee.calendar'].create({ start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
'employee_id': self.employee.id,
'date_start': Date.to_date("2025-01-01"),
'date_end': None,
'calendar_id': self.base_calendar.id,
})
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week
timesheet_sheet = self._create_timesheet_sheet(start_date) 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 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 # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour
@@ -99,12 +88,6 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 0, "timesheet_sheet_recovery_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): def test_positive_recovery_hours(self):
self.env['hr.employee.calendar'].create({
'employee_id': self.employee.id,
'date_start': Date.to_date("2025-01-01"),
'date_end': None,
'calendar_id': self.base_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) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date) 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 for stat in self._create_stats(start_date, 5, 8): #créer 5 stats de 8h chacune
@@ -121,15 +104,9 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) 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(len(recovery_allocation), 1, "There should be one recovery")
self.assertEqual(recovery_allocation.number_of_days,0.8928571428571429, "The recovery allocation should be for 6.25h/7h = 0.8928571428571429 day") 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): def test_negative_recovery_hours(self):
self.env['hr.employee.calendar'].create({
'employee_id': self.employee.id,
'date_start': Date.to_date("2025-01-01"),
'date_end': None,
'calendar_id': self.base_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) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date) 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 for stat in self._create_stats(start_date, 5, 6): #créer 5 stats de 6h chacune
@@ -146,7 +123,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) 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(len(recovery_allocation), 1, "There should be one recovery")
self.assertEqual(recovery_allocation.number_of_days, -0.7142857142857143, "The recovery allocation should be for -5/7 hours = 0,714285714") 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): def test_recovery_hours_part_time_employee(self):
part_time_calendar = self.env['resource.calendar'].create({ part_time_calendar = self.env['resource.calendar'].create({
@@ -162,12 +139,8 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}),
], ],
}) })
self.env['hr.employee.calendar'].create({ self.employee.resource_calendar_id = part_time_calendar.id
'employee_id': self.employee.id,
'date_start': Date.to_date("2025-01-01"),
'date_end': None,
'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) # lundi de la semaine dernière
timesheet_sheet = self._create_timesheet_sheet(start_date) 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 for stat in self._create_stats(start_date, 4, 8): #créer 4 stats de 8h chacune
@@ -181,9 +154,8 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
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_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) 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_calendar(self): def test_recovery_hours_change_contract(self):
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday) part_time_calendar = self.env['resource.calendar'].create({
employee_part_time_calendar = self.env['resource.calendar'].create({
'name': 'Part Time Calendar', 'name': 'Part Time Calendar',
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
@@ -196,31 +168,35 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (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
#create two hr.employee.calendar to change calendar during the timesheet period self.env['hr.contract'].create({
self.env['hr.employee.calendar'].create({ 'name': 'Contract 1',
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': Date.to_date("2023-07-01"), 'date_start': date.today() - timedelta(days=300), # date de début factice
'date_end': Date.to_date("2025-07-31"), 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière
'calendar_id': employee_full_time_calendar.id, 'resource_calendar_id': self.base_calendar.id,
'wage': 2000,
'state': 'close',
}) })
self.env['hr.employee.calendar'].create({ self.env['hr.contract'].create({
'name': 'Contract 2',
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': Date.to_date("2025-08-01"), 'state': 'open',
'date_end': None, 'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # date de début le jeudi de la semaine dernière
'calendar_id': employee_part_time_calendar.id, 'date_end': False,
'resource_calendar_id': self.base_calendar.id,
'wage': 1500,
}) })
self.employee.resource_calendar_id = employee_part_time_calendar.id 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 recovery hours on a period including the change of calendar #create a timesheet with period including the change of contract
timesheet_sheet = self._create_timesheet_sheet(Date.to_date("2025-07-28")) #a week including the change of calendar on 1st august timesheet_sheet = self._create_timesheet_sheet(start_date)
#the create of recovery allocation should raise an error #the create of recovery allocation should raise an error
with self.assertRaises(UserError): with self.assertRaises(UserError):
timesheet_sheet.action_generate_recovery_allocation() timesheet_sheet.action_generate_recovery_allocation()
def test_recovery_hours_change_calendar_sucess(self): def test_recovery_hours_change_contract_sucess(self):
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday) part_time_calendar = self.env['resource.calendar'].create({
employee_part_time_calendar = self.env['resource.calendar'].create({
'name': 'Part Time Calendar', 'name': 'Part Time Calendar',
'attendance_ids': [ 'attendance_ids': [
(0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}),
@@ -233,160 +209,39 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
(0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), (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
#create two hr.employee.calendar to change calendar during the timesheet period self.env['hr.contract'].create({
self.env['hr.employee.calendar'].create({ 'name': 'Contract 1',
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': Date.to_date("2023-07-01"), 'date_start': date(2025,8,18),
'date_end': Date.to_date("2025-07-31"), 'date_end': date(2025,8,24),
'calendar_id': employee_full_time_calendar.id, 'resource_calendar_id': self.base_calendar.id,
'wage': 2000,
'state': 'close',
}) })
self.env['hr.employee.calendar'].create({ self.env['hr.contract'].create({
'name': 'Contract 2',
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'date_start': Date.to_date("2025-08-01"), 'state': 'open',
'date_end': None, 'date_start': date(2025,8,25),
'calendar_id': employee_part_time_calendar.id, 'date_end': date(2025,8,31),
'resource_calendar_id': part_time_calendar.id,
'wage': 1500,
}) })
self.employee.resource_calendar_id = employee_part_time_calendar.id self.employee.resource_calendar_id = part_time_calendar.id
#create a timesheet with period including the change of contract
#create stats during period of full time calendar for the employee timesheet_sheet_1 = self._create_timesheet_sheet(date(2025,8,18))
timesheet_sheet = self.env['hr_timesheet.sheet'].create({ timesheet_sheet_2 = self._create_timesheet_sheet(date(2025,8,25))
'employee_id': self.employee.id,
'date_start': "2025-07-07",
'date_end': "2025-07-13",
})
stats = self._create_stats(Date.to_date("2025-07-07"), 5, 7)
for stat in stats:
stat._compute_dayofweek()
stat._compute_hours()
print("stat :", stat.date, stat.total_hours, stat.total_planned_hours, stat.gap_hours)
timesheet_sheet.action_generate_recovery_allocation()
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",)
#create stats during period of part time calendar for the employee
stats = self._create_stats(Date.to_date("2025-09-08"), 5, 7)
for stat in stats:
stat._compute_dayofweek()
stat._compute_hours()
timesheet_sheet = self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': "2025-09-08",
'date_end': "2025-09-14",
})
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",)
def test_recovery_allocation_doesnt_change_with_of_calendar(self):
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday)
employee_full_time_calendar.hours_per_day = 7
employee_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': 19, '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': 19, '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': 19, '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': 19, 'day_period': 'afternoon'}),
],
})
employee_part_time_calendar.hours_per_day = 9
#create two hr.employee.calendar to change calendar during the timesheet period
self.env['hr.employee.calendar'].create({
'employee_id': self.employee.id,
'date_start': Date.to_date("2023-07-01"),
'date_end': Date.to_date("2025-07-31"),
'calendar_id': employee_full_time_calendar.id,
})
self.env['hr.employee.calendar'].create({
'employee_id': self.employee.id,
'date_start': Date.to_date("2025-08-01"),
'date_end': None,
'calendar_id': employee_part_time_calendar.id,
})
self.employee.resource_calendar_id = employee_part_time_calendar.id
#create stats during period of full time calendar for the employee
timesheet_sheet_1 = self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': "2025-07-07",
'date_end': "2025-07-13",
})
stats = self._create_stats(Date.to_date("2025-07-07"), 5, 8)
for stat in stats:
stat._compute_dayofweek()
stat._compute_hours()
print("stat :", stat.date, stat.total_hours, stat.total_planned_hours, stat.gap_hours)
timesheet_sheet_1.action_generate_recovery_allocation() timesheet_sheet_1.action_generate_recovery_allocation()
self.assertEqual(timesheet_sheet_1.timesheet_sheet_gap_hours, 5, "timesheet_sheet_gap_hours should be 0",)
self.assertEqual(timesheet_sheet_1.timesheet_sheet_recovery_hours, 6.25, "timesheet_sheet_recovery_hours should be 6,25",)
allocation_1 = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)])
self.assertEqual(len(allocation_1), 1, "There should be one recovery")
self.assertEqual(allocation_1.number_of_days,0.8928571428571429, "The recovery allocation should be for 0.8928571428571429 day")
#create stats during period of part time calendar for the employee
# generation 4 stats of 10h each, the employee is supposed to work 9h per day during 4 days
stats_2 = self._create_stats(Date.to_date("2025-09-08"), 4, 10)
for stat in stats_2:
stat._compute_dayofweek()
stat._compute_hours()
timesheet_sheet_2 = self.env['hr_timesheet.sheet'].create({
'employee_id': self.employee.id,
'date_start': "2025-09-08",
'date_end': "2025-09-14",
})
timesheet_sheet_2.action_generate_recovery_allocation() timesheet_sheet_2.action_generate_recovery_allocation()
self.assertEqual(timesheet_sheet_2.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",)
self.assertEqual(timesheet_sheet_2.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",)
allocation_2 = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
self.assertEqual(len(allocation_2), 1, "There should be one recovery")
self.assertEqual(allocation_2.number_of_days,0.5555555555555556, "The recovery allocation should be for 0,555555556 (5/9) day")
#check that allocation_1 hasn't changed
self.assertEqual(allocation_1.number_of_days,0.8928571428571429, "The recovery allocation should be for 0,892857143 day")
def test_public_holiday(self): recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)])
self.env['hr.employee.calendar'].create({ self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
'employee_id': self.employee.id,
'date_start': Date.to_date("2025-01-01"), recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
'date_end': None, self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
'calendar_id': self.base_calendar.id,
})
# create a public holiday :
# When you create holidays graphically with a TZ,
# they are saved in the database after conversion to UTC.
# This is why, for a holiday starting on May 1, 2025, at 00:00:00 UTC+2,
# it will be saved in the database as April 30, 2025, at 22:00:00.
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",
})
# the employee has worked 7h on first may (public holiday) instead of 0h
# so the gap hours should be 7h and recovery hours 8,75h with coef 25%
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")

View File

@@ -15,11 +15,27 @@ class HolidaysType(models.Model):
allows_negative = fields.Boolean(string='Allow Negative Leaves', allows_negative = fields.Boolean(string='Allow Negative Leaves',
help="If checked, users request can exceed the allocated days and balance can go in negative.") 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') @api.depends('requires_allocation')
def _compute_valid(self): def _compute_valid(self):
res = super()._compute_valid() res = super()._compute_valid()
for holiday_type in res: for holiday_type in res:
#if negative is allowed, then the holiday type is valid in any case
if not holiday_type.has_valid_allocation: if not holiday_type.has_valid_allocation:
holiday_type.has_valid_allocation = holiday_type.allows_negative holiday_type.has_valid_allocation = holiday_type.allows_negative
@@ -139,9 +155,9 @@ class HolidaysType(models.Model):
continue continue
# calculate the number of days/hours for this allocation (allocation days/hours - leaves already taken) # 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'] 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 # # 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 negative is allowed for this leave type, we can exceed the number of available days in this allocation
if holiday_status_id.allows_negative: if holiday_status_id.allows_negative:
max_leaves = leave_duration max_leaves = leave_duration
@@ -149,9 +165,6 @@ class HolidaysType(models.Model):
# if negative is not allowed for this leave type, then we cannot exceed the allocation amount # 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 # 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) max_leaves = min(virtual_remaining_leaves, leave_duration)
####################################################
# END OF Modification for leaves allowing negative #
####################################################
# the new calculation of days taken for this allocation is previous taken + max_leaves (which can never exceed the allocation total) # 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 days_consumed[available_allocation]['virtual_leaves_taken'] += max_leaves
if leave.state == 'validate': if leave.state == 'validate':

View File

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

View File

@@ -1,91 +0,0 @@
from datetime import date, timedelta
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
@tagged('post_install', '-at_install')
class TestHrNegativeLeave(TransactionCase):
def setUp(self):
super().setUp()
# create a simple employee
self.employee = self.env['hr.employee'].create({
'name': 'NegTest Employee',
})
# create a user
self.user = self.env['res.users'].create({
'name': 'Test user',
'login': 'test user',
'employee_id': self.employee.id,
})
# prepare a leave type and an allocation with 1 day available
self.leave_type = self.env['hr.leave.type'].create({
'name': 'NegTest Type',
'request_unit': 'day',
'requires_allocation': 'yes',
'allows_negative': False,
})
self.allocation = self.env['hr.leave.allocation'].create({
'name': 'Alloc 1d',
'employee_id': self.employee.id,
'holiday_status_id': self.leave_type.id,
'number_of_days': 1.0,
'date_from': date.today() - timedelta(days=1),
'allocation_type': 'regular',
})
def test_negative_not_allowed_raises(self):
self.allocation.action_validate()
#self.leave_type._compute_leaves()
"""If the leave type does NOT allow negative, trying to confirm a leave
that exceeds allocations should raise a UserError."""
leave = self.env['hr.leave'].create({
'name': 'Too many days',
'employee_id': self.employee.id,
'holiday_status_id': self.leave_type.id,
'request_date_from': date.today(),
'request_date_to': date.today() + timedelta(days=1),
'number_of_days': 2.0,
'state': 'draft',
})
# self.leave_type._compute_leaves()
with self.assertRaises(UserError):
leave.write({'state': 'validate'})
def test_negative_allowed_allows_excess(self):
self.env.user = self.user
self.env.user.employee_id = self.employee
self.allocation.action_validate()
"""If the leave type allows negative, confirming a leave that exceeds
allocations must NOT raise an error."""
# flip the flag on the leave type
self.leave_type.allows_negative = True
leave = self.env['hr.leave'].create({
'name': 'Too many days',
'employee_id': self.employee.id,
'holiday_status_id': self.leave_type.id,
'date_from': date.today(),
'date_to': date.today() + timedelta(days=1),
'number_of_days': 2.0,
'state': 'draft',
})
# should not raise
leave.write({'state': 'confirm'})
# check remaining leaves is negative
self.leave_type._compute_leaves()
# Allocated in time off popup (Alloué)
self.assertEqual(self.leave_type.max_leaves, 1, "max_leaves should be 1",)
# Approuved in time off popup (Approuvé)
self.assertEqual(self.leave_type.virtual_leaves_taken, 2, "virtual_leaves_taken should be 2",)
# Remaining in time off popup (Restants)
self.assertEqual(self.leave_type.max_leaves - self.leave_type.virtual_leaves_taken, -1, "remaining leaves should display in timeoff popup -1",)

View File

@@ -1,6 +1,6 @@
{ {
"name": "hr_timesheet_sheet_usability_misc", "name": "hr_timesheet_sheet_usability",
"version": "16.0.1.1.0", "version": "16.0.1.0.0",
"description": "Various changes to improve the usability of hr_timesheet_sheet application", "description": "Various changes to improve the usability of hr_timesheet_sheet application",
"summary": "", "summary": "",
"author": "Elabore", "author": "Elabore",