Compare commits
18 Commits
3bfe2752b5
...
16.0-fix-f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e4342fba2 | |||
| 01cf81e8bb | |||
| e1534c41d8 | |||
| e61e2792a9 | |||
| 9f061ff409 | |||
| a2ebda5898 | |||
| 8ac80079a0 | |||
| 5b103056d6 | |||
| d2301b765e | |||
| 67b18277c1 | |||
| c16fccfa6e | |||
| 726e34febc | |||
| ee1e1cbe65 | |||
| 004187d9a8 | |||
| 2e392a5afd | |||
| 4d76c78863 | |||
| f7c7496492 | |||
| fd5e0f2f44 |
@@ -1,43 +0,0 @@
|
|||||||
==========================================================
|
|
||||||
allow_negative_leave_and_allocation_hr_holidays_attendance
|
|
||||||
==========================================================
|
|
||||||
|
|
||||||
manage heritance of Duration in TimeOffCard
|
|
||||||
|
|
||||||
Installation
|
|
||||||
============
|
|
||||||
|
|
||||||
The module self-installs when ``allow_negative_leave_and_allocation`` and ``hr_holidays_attendance``.
|
|
||||||
|
|
||||||
Known issues / Roadmap
|
|
||||||
======================
|
|
||||||
|
|
||||||
None yet.
|
|
||||||
|
|
||||||
Bug Tracker
|
|
||||||
===========
|
|
||||||
|
|
||||||
Bugs are tracked on `our issues website <https://github.com/elabore-coop/allow_negative_leave_and_allocation_hr_holidays_attendance/issues>`_. In case of
|
|
||||||
trouble, please check there if your issue has already been
|
|
||||||
reported. If you spotted it first, help us smashing it by providing a
|
|
||||||
detailed and welcomed feedback.
|
|
||||||
|
|
||||||
Credits
|
|
||||||
=======
|
|
||||||
|
|
||||||
Contributors
|
|
||||||
------------
|
|
||||||
|
|
||||||
* `Elabore <mailto:laetitia.dacosta@elabore.coop>`
|
|
||||||
|
|
||||||
Funders
|
|
||||||
-------
|
|
||||||
|
|
||||||
The development of this module has been financially supported by:
|
|
||||||
* Elabore (https://elabore.coop)
|
|
||||||
|
|
||||||
|
|
||||||
Maintainer
|
|
||||||
----------
|
|
||||||
|
|
||||||
This module is maintained by Elabore.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<template>
|
|
||||||
<t t-name="allow_negative_hr_holidays_attendance.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.overtime_deductible
|
|
||||||
? data.usable_remaining_leaves
|
|
||||||
: data.virtual_leaves_taken" />
|
|
||||||
</xpath>
|
|
||||||
</t>
|
|
||||||
</template>
|
|
||||||
@@ -27,7 +27,7 @@ None yet.
|
|||||||
Bug Tracker
|
Bug Tracker
|
||||||
===========
|
===========
|
||||||
|
|
||||||
Bugs are tracked on `our issues website <https://github.com/elabore-coop/allow_negative_leave_and_allocation/issues>`_. In case of
|
Bugs are tracked on `our issues website <https://git.elabore.coop/Elabore/hr-tools/issues>`_. In case of
|
||||||
trouble, please check there if your issue has already been
|
trouble, please check there if your issue has already been
|
||||||
reported. If you spotted it first, help us smashing it by providing a
|
reported. If you spotted it first, help us smashing it by providing a
|
||||||
detailed and welcomed feedback.
|
detailed and welcomed feedback.
|
||||||
@@ -39,7 +39,7 @@ Contributors
|
|||||||
------------
|
------------
|
||||||
|
|
||||||
* `Alusage : Nicolas JEUDY`
|
* `Alusage : Nicolas JEUDY`
|
||||||
* `Elabore <mailto:laetitia.dacosta@elabore.coop>`
|
* `Elabore <mailto:contact@elabore.coop>`
|
||||||
|
|
||||||
Funders
|
Funders
|
||||||
-------
|
-------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hr_employee_stats_sheet",
|
"name": "hr_employee_stats_sheet",
|
||||||
"version": "16.0.2.0.0",
|
"version": "16.0.3.1.3",
|
||||||
"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",
|
||||||
@@ -8,13 +8,15 @@
|
|||||||
"license": "LGPL-3",
|
"license": "LGPL-3",
|
||||||
"category": "Human Resources",
|
"category": "Human Resources",
|
||||||
"depends": [
|
"depends": [
|
||||||
"allow_negative_leave_and_allocation",
|
"hr_negative_leave",
|
||||||
"base",
|
"base",
|
||||||
"hr",
|
"hr",
|
||||||
"hr_holidays",
|
"hr_holidays",
|
||||||
"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",
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ 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
|
||||||
38
hr_employee_stats_sheet/models/hr_employee.py
Normal file
38
hr_employee_stats_sheet/models/hr_employee.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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
|
||||||
_logger = logging.getLogger(__name__)
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class HrEmployeeStats(models.Model):
|
class HrEmployeeStats(models.Model):
|
||||||
@@ -17,11 +18,10 @@ 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.One2many(
|
timesheet_line_ids = fields.Many2many(
|
||||||
"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.resource_calendar_id
|
calendar_id = self.employee_id._get_calendar_in_progress_during_a_time_period(self.date,self.date)
|
||||||
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,11 +119,17 @@ 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):
|
||||||
@@ -131,22 +137,23 @@ 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_id = leave.search(
|
leave_ids = 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_id:
|
if leave_ids:
|
||||||
if leave_id.request_unit_hours:
|
for leave_id in leave_ids:
|
||||||
total_leave_hours = leave_id.number_of_hours_display
|
if leave_id.request_unit_hours:
|
||||||
elif leave_id.request_unit_half:
|
leave_hours = leave_id.number_of_hours_display
|
||||||
total_leave_hours = self._get_total_planned_hours() / 2
|
total_leave_hours += min(leave_hours,self._get_total_planned_hours())
|
||||||
else :
|
elif leave_id.request_unit_half:
|
||||||
total_leave_hours = self._get_total_planned_hours()
|
total_leave_hours += self._get_total_planned_hours() / 2
|
||||||
|
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")
|
||||||
@@ -162,8 +169,44 @@ 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 = 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
|
||||||
|
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 = (
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ class HrTimesheetSheet(models.Model):
|
|||||||
sheet.timesheet_sheet_recovery_hours = sheet._get_timesheet_sheet_recovery_hours()
|
sheet.timesheet_sheet_recovery_hours = sheet._get_timesheet_sheet_recovery_hours()
|
||||||
|
|
||||||
def _get_timesheet_sheet_gap_hours(self):
|
def _get_timesheet_sheet_gap_hours(self):
|
||||||
for sheet in self:
|
self.ensure_one()
|
||||||
timesheet_sheet_gap_hours = sum(
|
timesheet_sheet_gap_hours = sum(
|
||||||
sheet.employee_stats_ids.filtered(
|
self.employee_stats_ids.filtered(
|
||||||
lambda stat: stat.date <= fields.Date.today()
|
lambda stat: stat.date <= fields.Date.today()
|
||||||
).mapped("gap_hours")
|
).mapped("gap_hours")
|
||||||
)
|
)
|
||||||
return timesheet_sheet_gap_hours
|
return timesheet_sheet_gap_hours
|
||||||
|
|
||||||
def _get_timesheet_sheet_recovery_hours(self):
|
def _get_timesheet_sheet_recovery_hours(self):
|
||||||
@@ -100,65 +100,6 @@ 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:
|
||||||
@@ -169,17 +110,11 @@ 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._get_calendar_in_progress_during_timesheet_time_period()
|
ressource_calendar_id = self.employee_id._get_calendar_in_progress_during_a_time_period(self.date_start,self.date_end)
|
||||||
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()
|
||||||
@@ -189,7 +124,7 @@ class HrTimesheetSheet(models.Model):
|
|||||||
|
|
||||||
if not employee_id or not recovery_type_id:
|
if not employee_id or not recovery_type_id:
|
||||||
raise UserError(
|
raise UserError(
|
||||||
_("Employe not defined for the timesheet sheet or recovery type not defined in settings")
|
_("Employee not defined for the timesheet sheet or recovery type not defined in settings")
|
||||||
)
|
)
|
||||||
if recovery_type_id.request_unit != 'hour' or not recovery_type_id.allows_negative:
|
if recovery_type_id.request_unit != 'hour' or not recovery_type_id.allows_negative:
|
||||||
raise UserError(
|
raise UserError(
|
||||||
@@ -212,25 +147,6 @@ 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()
|
||||||
|
|
||||||
@@ -243,6 +159,7 @@ 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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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
|
from datetime import date, timedelta, datetime
|
||||||
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):
|
||||||
@@ -19,6 +20,7 @@ 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',
|
||||||
@@ -35,11 +37,13 @@ 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):
|
||||||
#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({
|
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,
|
||||||
@@ -47,7 +51,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _create_stats(self, start_date, nb_days, unit_amount):
|
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):
|
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,
|
||||||
@@ -56,16 +60,17 @@ class TestHrEmployeeStatsRecovery(TransactionCase):
|
|||||||
'account_id': 1,
|
'account_id': 1,
|
||||||
'name': 'Work Entry',
|
'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({
|
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) # 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.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)
|
||||||
@@ -74,7 +79,13 @@ 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):
|
||||||
start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière
|
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) # 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
|
||||||
@@ -88,6 +99,12 @@ 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
|
||||||
@@ -104,9 +121,15 @@ 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.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):
|
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
|
||||||
@@ -123,7 +146,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.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,714285714")
|
||||||
|
|
||||||
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({
|
||||||
@@ -139,8 +162,12 @@ 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.employee.resource_calendar_id = part_time_calendar.id
|
self.env['hr.employee.calendar'].create({
|
||||||
|
'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
|
||||||
@@ -154,8 +181,9 @@ 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_contract(self):
|
def test_recovery_hours_change_calendar(self):
|
||||||
part_time_calendar = self.env['resource.calendar'].create({
|
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday)
|
||||||
|
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'}),
|
||||||
@@ -168,35 +196,31 @@ 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
|
|
||||||
self.env['hr.contract'].create({
|
#create two hr.employee.calendar to change calendar during the timesheet period
|
||||||
'name': 'Contract 1',
|
self.env['hr.employee.calendar'].create({
|
||||||
'employee_id': self.employee.id,
|
'employee_id': self.employee.id,
|
||||||
'date_start': date.today() - timedelta(days=300), # date de début factice
|
'date_start': Date.to_date("2023-07-01"),
|
||||||
'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière
|
'date_end': Date.to_date("2025-07-31"),
|
||||||
'resource_calendar_id': self.base_calendar.id,
|
'calendar_id': employee_full_time_calendar.id,
|
||||||
'wage': 2000,
|
|
||||||
'state': 'close',
|
|
||||||
})
|
})
|
||||||
self.env['hr.contract'].create({
|
self.env['hr.employee.calendar'].create({
|
||||||
'name': 'Contract 2',
|
|
||||||
'employee_id': self.employee.id,
|
'employee_id': self.employee.id,
|
||||||
'state': 'open',
|
'date_start': Date.to_date("2025-08-01"),
|
||||||
'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # date de début le jeudi de la semaine dernière
|
'date_end': None,
|
||||||
'date_end': False,
|
'calendar_id': employee_part_time_calendar.id,
|
||||||
'resource_calendar_id': self.base_calendar.id,
|
|
||||||
'wage': 1500,
|
|
||||||
})
|
})
|
||||||
self.employee.resource_calendar_id = part_time_calendar.id
|
self.employee.resource_calendar_id = employee_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
|
#create recovery hours on a period including the change of calendar
|
||||||
timesheet_sheet = self._create_timesheet_sheet(start_date)
|
timesheet_sheet = self._create_timesheet_sheet(Date.to_date("2025-07-28")) #a week including the change of calendar on 1st august
|
||||||
#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_contract_sucess(self):
|
def test_recovery_hours_change_calendar_sucess(self):
|
||||||
part_time_calendar = self.env['resource.calendar'].create({
|
employee_full_time_calendar = self.base_calendar # full time calendar (from monday to friday)
|
||||||
|
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'}),
|
||||||
@@ -209,39 +233,160 @@ 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
|
|
||||||
self.env['hr.contract'].create({
|
#create two hr.employee.calendar to change calendar during the timesheet period
|
||||||
'name': 'Contract 1',
|
self.env['hr.employee.calendar'].create({
|
||||||
'employee_id': self.employee.id,
|
'employee_id': self.employee.id,
|
||||||
'date_start': date(2025,8,18),
|
'date_start': Date.to_date("2023-07-01"),
|
||||||
'date_end': date(2025,8,24),
|
'date_end': Date.to_date("2025-07-31"),
|
||||||
'resource_calendar_id': self.base_calendar.id,
|
'calendar_id': employee_full_time_calendar.id,
|
||||||
'wage': 2000,
|
|
||||||
'state': 'close',
|
|
||||||
})
|
})
|
||||||
self.env['hr.contract'].create({
|
self.env['hr.employee.calendar'].create({
|
||||||
'name': 'Contract 2',
|
|
||||||
'employee_id': self.employee.id,
|
'employee_id': self.employee.id,
|
||||||
'state': 'open',
|
'date_start': Date.to_date("2025-08-01"),
|
||||||
'date_start': date(2025,8,25),
|
'date_end': None,
|
||||||
'date_end': date(2025,8,31),
|
'calendar_id': employee_part_time_calendar.id,
|
||||||
'resource_calendar_id': part_time_calendar.id,
|
|
||||||
'wage': 1500,
|
|
||||||
})
|
})
|
||||||
self.employee.resource_calendar_id = part_time_calendar.id
|
self.employee.resource_calendar_id = employee_part_time_calendar.id
|
||||||
#create a timesheet with period including the change of contract
|
|
||||||
timesheet_sheet_1 = self._create_timesheet_sheet(date(2025,8,18))
|
#create stats during period of full time calendar for the employee
|
||||||
timesheet_sheet_2 = self._create_timesheet_sheet(date(2025,8,25))
|
timesheet_sheet = 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, 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")
|
||||||
|
|
||||||
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)])
|
def test_public_holiday(self):
|
||||||
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
|
self.env['hr.employee.calendar'].create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)])
|
'date_start': Date.to_date("2025-01-01"),
|
||||||
self.assertEqual(len(recovery_allocation), 1, "There should be one recovery")
|
'date_end': None,
|
||||||
|
'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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
44
hr_negative_leave/README.rst
Normal file
44
hr_negative_leave/README.rst
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
===================================
|
||||||
|
allow_negative_leave_and_allocation
|
||||||
|
===================================
|
||||||
|
|
||||||
|
allow negative leaves, manage negative leave balances and negative allocations
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
Use Odoo normal module installation procedure to install
|
||||||
|
``allow_negative_leave_and_allocation``.
|
||||||
|
|
||||||
|
Known issues / Roadmap
|
||||||
|
======================
|
||||||
|
|
||||||
|
None yet.
|
||||||
|
|
||||||
|
Bug Tracker
|
||||||
|
===========
|
||||||
|
|
||||||
|
Bugs are tracked on `our issues website <https://git.elabore.coop/Elabore/hr-tools/issues>`_. In case of
|
||||||
|
trouble, please check there if your issue has already been
|
||||||
|
reported. If you spotted it first, help us smashing it by providing a
|
||||||
|
detailed and welcomed feedback.
|
||||||
|
|
||||||
|
Credits
|
||||||
|
=======
|
||||||
|
|
||||||
|
Contributors
|
||||||
|
------------
|
||||||
|
|
||||||
|
* `Elabore <mailto:contact@elabore.coop>`
|
||||||
|
|
||||||
|
Funders
|
||||||
|
-------
|
||||||
|
|
||||||
|
The development of this module has been financially supported by:
|
||||||
|
* Elabore (https://elabore.coop)
|
||||||
|
|
||||||
|
|
||||||
|
Maintainer
|
||||||
|
----------
|
||||||
|
|
||||||
|
This module is maintained by Elabore.
|
||||||
1
hr_negative_leave/__init__.py
Normal file
1
hr_negative_leave/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
# Copyright 2025 Elabore ()
|
# Copyright 2024 Elabore ()
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "allow_negative_leave_and_allocation_hr_holidays_attendance",
|
"name": "hr_negative_leave",
|
||||||
"version": "16.0.1.0.0",
|
"version": "16.0.3.0.0",
|
||||||
"author": "Elabore",
|
"author": "Elabore",
|
||||||
"website": "https://elabore.coop",
|
"website": "https://elabore.coop",
|
||||||
"maintainer": "Elabore",
|
"maintainer": "Elabore",
|
||||||
"license": "AGPL-3",
|
"license": "AGPL-3",
|
||||||
"category": "HR",
|
"category": "hr",
|
||||||
"summary": "manage heritance of Duration in TimeOffCard",
|
"summary": "allow negative leaves, manage negative leave balances and negative allocations",
|
||||||
# any module necessary for this one to work correctly
|
# any module necessary for this one to work correctly
|
||||||
"depends": [
|
"depends": [
|
||||||
"base","allow_negative_leave_and_allocation","hr_holidays_attendance",
|
"base","hr_holidays",
|
||||||
],
|
],
|
||||||
"qweb": [],
|
"qweb": [],
|
||||||
"external_dependencies": {
|
"external_dependencies": {
|
||||||
"python": [],
|
"python": [],
|
||||||
},
|
},
|
||||||
# always loaded
|
# always loaded
|
||||||
"data": [],
|
"data": [
|
||||||
"assets": {
|
"views/hr_leave_type_views.xml",
|
||||||
'web.assets_backend': [
|
"views/hr_leave_views.xml",
|
||||||
'allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml',
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
# only loaded in demonstration mode
|
# only loaded in demonstration mode
|
||||||
"demo": [],
|
"demo": [],
|
||||||
"js": [],
|
"js": [],
|
||||||
@@ -32,6 +30,6 @@
|
|||||||
"installable": True,
|
"installable": True,
|
||||||
# Install this module automatically if all dependency have been previously
|
# Install this module automatically if all dependency have been previously
|
||||||
# and independently installed. Used for synergetic or glue modules.
|
# and independently installed. Used for synergetic or glue modules.
|
||||||
"auto_install": True,
|
"auto_install": False,
|
||||||
"application": False,
|
"application": False,
|
||||||
}
|
}
|
||||||
54
hr_negative_leave/i18n/fr.po
Normal file
54
hr_negative_leave/i18n/fr.po
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * hr_negative_leave
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 16.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-10-31 08:25+0000\n"
|
||||||
|
"PO-Revision-Date: 2025-10-31 08:25+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave_type__allows_negative
|
||||||
|
msgid "Allow Negative Leaves"
|
||||||
|
msgstr "Autoriser les demandes et les soldes de congés négatifs"
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model_terms:ir.ui.view,arch_db:hr_negative_leave.hr_leave_type_negative_leave
|
||||||
|
msgid "Allow negative"
|
||||||
|
msgstr "Autoriser les soldes négatifs"
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,help:hr_negative_leave.field_hr_leave_type__allows_negative
|
||||||
|
msgid ""
|
||||||
|
"If checked, users request can exceed the allocated days and balance can go "
|
||||||
|
"in negative."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave_type__remaining_leaves_allowing_negative
|
||||||
|
msgid "Remaining Leaves when Negative Allowed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave__smart_search
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave_type__smart_search
|
||||||
|
msgid "Smart Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model,name:hr_negative_leave.model_hr_leave
|
||||||
|
msgid "Time Off"
|
||||||
|
msgstr "Congés"
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model,name:hr_negative_leave.model_hr_leave_type
|
||||||
|
msgid "Time Off Type"
|
||||||
|
msgstr "Type de congés"
|
||||||
54
hr_negative_leave/i18n/hr_negative_leave.pot
Normal file
54
hr_negative_leave/i18n/hr_negative_leave.pot
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Translation of Odoo Server.
|
||||||
|
# This file contains the translation of the following modules:
|
||||||
|
# * hr_negative_leave
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Odoo Server 16.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-10-31 08:23+0000\n"
|
||||||
|
"PO-Revision-Date: 2025-10-31 08:23+0000\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave_type__allows_negative
|
||||||
|
msgid "Allow Negative Leaves"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model_terms:ir.ui.view,arch_db:hr_negative_leave.hr_leave_type_negative_leave
|
||||||
|
msgid "Allow negative"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,help:hr_negative_leave.field_hr_leave_type__allows_negative
|
||||||
|
msgid ""
|
||||||
|
"If checked, users request can exceed the allocated days and balance can go "
|
||||||
|
"in negative."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave_type__remaining_leaves_allowing_negative
|
||||||
|
msgid "Remaining Leaves when Negative Allowed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave__smart_search
|
||||||
|
#: model:ir.model.fields,field_description:hr_negative_leave.field_hr_leave_type__smart_search
|
||||||
|
msgid "Smart Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model,name:hr_negative_leave.model_hr_leave
|
||||||
|
msgid "Time Off"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#. module: hr_negative_leave
|
||||||
|
#: model:ir.model,name:hr_negative_leave.model_hr_leave_type
|
||||||
|
msgid "Time Off Type"
|
||||||
|
msgstr ""
|
||||||
1
hr_negative_leave/models/__init__.py
Normal file
1
hr_negative_leave/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import hr_leave_type, hr_leave
|
||||||
16
hr_negative_leave/models/hr_leave.py
Normal file
16
hr_negative_leave/models/hr_leave.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# -*- 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 api, models
|
||||||
|
|
||||||
|
class HrLeave(models.Model):
|
||||||
|
_inherit = "hr.leave"
|
||||||
|
|
||||||
|
@api.constrains('state', 'number_of_days', 'holiday_status_id')
|
||||||
|
def _check_holidays(self):
|
||||||
|
# Keep only leaves that do not allow negative balances
|
||||||
|
to_check = self.filtered(lambda h: not h.holiday_status_id.allows_negative)
|
||||||
|
if to_check:
|
||||||
|
super(HrLeave, to_check)._check_holidays()
|
||||||
228
hr_negative_leave/models/hr_leave_type.py
Normal file
228
hr_negative_leave/models/hr_leave_type.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
|
||||||
|
# -*- 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"
|
||||||
|
|
||||||
|
# negative time off
|
||||||
|
allows_negative = fields.Boolean(string='Allow Negative Leaves',
|
||||||
|
help="If checked, users request can exceed the allocated days and balance can go in negative.")
|
||||||
|
|
||||||
|
@api.depends('requires_allocation')
|
||||||
|
def _compute_valid(self):
|
||||||
|
res = super()._compute_valid()
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
####################################################
|
||||||
|
# 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)
|
||||||
|
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
|
||||||
|
|
||||||
1
hr_negative_leave/tests/__init__.py
Normal file
1
hr_negative_leave/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import test_hr_negative_leave
|
||||||
91
hr_negative_leave/tests/test_hr_negative_leave.py
Normal file
91
hr_negative_leave/tests/test_hr_negative_leave.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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",)
|
||||||
18
hr_negative_leave/views/hr_leave_type_views.xml
Normal file
18
hr_negative_leave/views/hr_leave_type_views.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="hr_leave_type_negative_leave" model="ir.ui.view">
|
||||||
|
<field name="name">hr.leave.type.negative.leave</field>
|
||||||
|
<field name="model">hr.leave.type</field>
|
||||||
|
<field name="inherit_id" ref="hr_holidays.edit_holiday_status_form" />
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//group[@name='allocation_validation']" position="after">
|
||||||
|
<group name="negative_leave" id="negative_leave" colspan="4"
|
||||||
|
string="Allow negative"
|
||||||
|
attrs="{'invisible':[('requires_allocation', '=', 'no')]}"
|
||||||
|
>
|
||||||
|
<field name="allows_negative" />
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
22
hr_negative_leave/views/hr_leave_views.xml
Normal file
22
hr_negative_leave/views/hr_leave_views.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<odoo>
|
||||||
|
<record id="hr_leave_view_negative_leave" model="ir.ui.view">
|
||||||
|
<field name="name">hr.leave.view.negative.leave</field>
|
||||||
|
<field name="model">hr.leave</field>
|
||||||
|
<field name="inherit_id" ref="hr_holidays.hr_leave_view_form" />
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='holiday_status_id']" position="attributes">
|
||||||
|
<attribute name="domain">[
|
||||||
|
'|',
|
||||||
|
('requires_allocation', '=', 'no'),
|
||||||
|
'&',
|
||||||
|
('has_valid_allocation', '=', True),
|
||||||
|
'|',
|
||||||
|
('allows_negative', '=', True),
|
||||||
|
'&',
|
||||||
|
('virtual_remaining_leaves', '>', 0),
|
||||||
|
('allows_negative', '=', False),
|
||||||
|
]</attribute>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -29,7 +29,7 @@ None yet.
|
|||||||
Bug Tracker
|
Bug Tracker
|
||||||
===========
|
===========
|
||||||
|
|
||||||
Bugs are tracked on `our issues website <https://github.com/elabore-coop/allow_negative_leave_and_allocation/issues>`_. In case of
|
Bugs are tracked on `our issues website <https://git.elabore.coop/Elabore/hr-tools/issues>`_. In case of
|
||||||
trouble, please check there if your issue has already been
|
trouble, please check there if your issue has already been
|
||||||
reported. If you spotted it first, help us smashing it by providing a
|
reported. If you spotted it first, help us smashing it by providing a
|
||||||
detailed and welcomed feedback.
|
detailed and welcomed feedback.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hr_timesheet_sheet_usability",
|
"name": "hr_timesheet_sheet_usability_misc",
|
||||||
"version": "16.0.1.0.0",
|
"version": "16.0.1.1.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",
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "France - Time Off",
|
"name": "France - Time Off",
|
||||||
"version": "16.0.1.0.0",
|
"version": "16.0.1.0.1",
|
||||||
"category": "Human Resources/Time Off",
|
"category": "Human Resources/Time Off",
|
||||||
"countries": ["fr"],
|
"countries": ["fr"],
|
||||||
"summary": "Management of leaves for part-time workers in France",
|
"summary": "Management of leaves for part-time workers in France",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from odoo import fields, models, _
|
from odoo import fields, models, _, api
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
class HrLeave(models.Model):
|
class HrLeave(models.Model):
|
||||||
@@ -11,6 +11,13 @@ class HrLeave(models.Model):
|
|||||||
resource_calendar_id = fields.Many2one('resource.calendar', compute='_compute_resource_calendar_id', store=True, readonly=False, copy=False)
|
resource_calendar_id = fields.Many2one('resource.calendar', compute='_compute_resource_calendar_id', store=True, readonly=False, copy=False)
|
||||||
l10n_fr_date_to_changed = fields.Boolean()
|
l10n_fr_date_to_changed = fields.Boolean()
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'holiday_type',
|
||||||
|
'employee_id',
|
||||||
|
'employee_id.resource_calendar_id',
|
||||||
|
'request_date_from',
|
||||||
|
'request_date_to',
|
||||||
|
)
|
||||||
def _compute_resource_calendar_id(self):
|
def _compute_resource_calendar_id(self):
|
||||||
for leave in self:
|
for leave in self:
|
||||||
calendar = False
|
calendar = False
|
||||||
@@ -36,6 +43,20 @@ class HrLeave(models.Model):
|
|||||||
# If there are more than one contract they should all have the
|
# If there are more than one contract they should all have the
|
||||||
# same calendar, otherwise a constraint is violated.
|
# same calendar, otherwise a constraint is violated.
|
||||||
calendar = contracts[:1].resource_calendar_id
|
calendar = contracts[:1].resource_calendar_id
|
||||||
|
# Crappy hack again : if hr_employee_calendar_planning is installed
|
||||||
|
# use planning calendar instead of contract calendar
|
||||||
|
if 'hr.employee.calendar' in self.env:
|
||||||
|
calendar_planning = self.env["hr.employee.calendar"].search(
|
||||||
|
[
|
||||||
|
("employee_id", "=", leave.employee_id.id),
|
||||||
|
("date_start", "<=", leave.request_date_to),
|
||||||
|
"|", ("date_end", "=", False), # pas de date de fin OU
|
||||||
|
("date_end", ">=", leave.request_date_from), # date de fin après le début
|
||||||
|
],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
if calendar_planning:
|
||||||
|
calendar = calendar_planning.calendar_id
|
||||||
elif leave.holiday_type == 'department':
|
elif leave.holiday_type == 'department':
|
||||||
calendar = leave.department_id.company_id.resource_calendar_id
|
calendar = leave.department_id.company_id.resource_calendar_id
|
||||||
elif leave.holiday_type == 'company':
|
elif leave.holiday_type == 'company':
|
||||||
|
|||||||
Reference in New Issue
Block a user