diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/README.rst b/allow_negative_leave_and_allocation_hr_holidays_attendance/README.rst deleted file mode 100644 index 396d11e..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/README.rst +++ /dev/null @@ -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 `_. 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 ` - -Funders -------- - -The development of this module has been financially supported by: -* Elabore (https://elabore.coop) - - -Maintainer ----------- - -This module is maintained by Elabore. \ No newline at end of file diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/__init__.py b/allow_negative_leave_and_allocation_hr_holidays_attendance/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml b/allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml deleted file mode 100644 index 4926de3..0000000 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml +++ /dev/null @@ -1,11 +0,0 @@ - \ No newline at end of file diff --git a/hr_employee_stats_sheet/README.rst b/hr_employee_stats_sheet/README.rst index c4b2416..15fa738 100644 --- a/hr_employee_stats_sheet/README.rst +++ b/hr_employee_stats_sheet/README.rst @@ -27,7 +27,7 @@ None yet. Bug Tracker =========== -Bugs are tracked on `our issues website `_. In case of +Bugs are tracked on `our issues website `_. 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. @@ -39,7 +39,7 @@ Contributors ------------ * `Alusage : Nicolas JEUDY` -* `Elabore ` +* `Elabore ` Funders ------- diff --git a/hr_employee_stats_sheet/__manifest__.py b/hr_employee_stats_sheet/__manifest__.py index 3d4aaf5..040e80c 100755 --- a/hr_employee_stats_sheet/__manifest__.py +++ b/hr_employee_stats_sheet/__manifest__.py @@ -1,6 +1,6 @@ { "name": "hr_employee_stats_sheet", - "version": "16.0.2.0.0", + "version": "16.0.2.1.0", "description": "Add global sheet for employee stats", "summary": "Add global sheet for employee stats", "author": "Nicolas JEUDY", @@ -8,7 +8,7 @@ "license": "LGPL-3", "category": "Human Resources", "depends": [ - "allow_negative_leave_and_allocation", + "hr_negative_leave", "base", "hr", "hr_holidays", diff --git a/hr_employee_stats_sheet/models/hr_employee_stats.py b/hr_employee_stats_sheet/models/hr_employee_stats.py index bf1ede0..ae170a2 100644 --- a/hr_employee_stats_sheet/models/hr_employee_stats.py +++ b/hr_employee_stats_sheet/models/hr_employee_stats.py @@ -1,7 +1,9 @@ import logging +import pytz from odoo import api, fields, models from datetime import timedelta +from pytz import utc _logger = logging.getLogger(__name__) @@ -162,8 +164,41 @@ class HrEmployeeStats(models.Model): stat.is_public_holiday = False continue stat.dayofweek = int(stat.date.strftime("%u")) - 1 - stat.is_public_holiday = bool(stat.sheet_id.employee_id._get_public_holidays(stat.date, stat.date - timedelta(days=1))) + stat.is_public_holiday = stat._is_public_holiday_accordig_to_employe_tz() + def _convert_to_employee_tz(self, date): + """Convert a UTC datetime to the employee's timezone datetime.""" + self.ensure_one() + if not date: + return None + employee_tz = pytz.timezone(self.employee_id.tz or "UTC") + if date.tzinfo is None: + dt = pytz.utc.localize(date) + return dt.astimezone(employee_tz) + + def _is_public_holiday_accordig_to_employe_tz(self): + self.ensure_one() + if not self.date or not self.employee_id: + return False + #get public holidays for the employee + public_holidays = self.employee_id._get_public_holidays( + self.date, self.date + ) + if not public_holidays: + return False + ph = public_holidays[0] + # Convert public holiday to the employee timezone + ph_datetime_from_tz = self._convert_to_employee_tz(ph.date_from) + ph_datetime_to_tz = self._convert_to_employee_tz(ph.date_to) + # Convert datetime to date + ph_date_from = ph_datetime_from_tz.date() + ph_date_to = ph_datetime_to_tz.date() + # Check if the stat date falls within the public holiday range after conversion in employee tz + if ph_date_from <= self.date <= ph_date_to: + return True + else: + return False + def _get_gap_hours(self, total_hours, total_recovery_hours, total_leave_hours, total_planned_hours): self.ensure_one() balance = ( diff --git a/hr_employee_stats_sheet/models/hr_timesheet_sheet.py b/hr_employee_stats_sheet/models/hr_timesheet_sheet.py index 867a0e1..1b22761 100644 --- a/hr_employee_stats_sheet/models/hr_timesheet_sheet.py +++ b/hr_employee_stats_sheet/models/hr_timesheet_sheet.py @@ -110,8 +110,8 @@ class HrTimesheetSheet(models.Model): ("state", "in", ("open", "close")), ("date_start", "<=", self.date_end), "|", - ("date_end", "=", False), # pas de date de fin OU - ("date_end", ">=", self.date_start), # date de fin après le début + ("date_end", "=", False), # no end date OR + ("date_end", ">=", self.date_start), # end date after start ], ) return contracts diff --git a/hr_employee_stats_sheet/tests/test_employee_stats.py b/hr_employee_stats_sheet/tests/test_employee_stats.py index 2594b3b..4ca14a6 100644 --- a/hr_employee_stats_sheet/tests/test_employee_stats.py +++ b/hr_employee_stats_sheet/tests/test_employee_stats.py @@ -1,7 +1,9 @@ -from odoo.tests.common import TransactionCase -from odoo.tests import tagged -from datetime import date, timedelta +from datetime import date, datetime, timedelta + from odoo.exceptions import UserError +from odoo.fields import Date +from odoo.tests import tagged +from odoo.tests.common import TransactionCase @tagged("post_install", "-at_install") class TestHrEmployeeStatsRecovery(TransactionCase): @@ -19,6 +21,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase): self.employee = self.env['hr.employee'].create({ 'name': 'Camille', 'user_id': self.user.id, + 'tz': 'Europe/Paris', }) self.base_calendar = self.env['resource.calendar'].create({ 'name': 'Default Calendar', @@ -35,11 +38,13 @@ class TestHrEmployeeStatsRecovery(TransactionCase): (0, 0, {'name': 'Friday Afternoon', 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), ], }) + self.base_calendar.hours_per_day = 7 self.employee.resource_calendar_id = self.base_calendar + self.env.company.recovery_type_id = self.recovery_type self.env.company.coef = 25 def _create_timesheet_sheet(self, start_date): - #Crée une feuille de temps pour la semaine du lundi au dimanche + # Create a timesheet for the week (Monday to Sunday) return self.env['hr_timesheet.sheet'].create({ 'employee_id': self.employee.id, 'date_start': start_date, @@ -47,7 +52,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase): }) def _create_stats(self, start_date, nb_days, unit_amount): - # Crée des temps du lundi au vendredi (ou nb_days) + # Create timesheet lines from Monday to Friday (or nb_days) for i in range(nb_days): self.env['account.analytic.line'].create({ 'employee_id': self.employee.id, @@ -56,16 +61,19 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 'account_id': 1, 'name': 'Work Entry', }) - # Génère les hr_employee_stats pour chaque jour de la période + # Generate hr.employee.stats for each day of the period stat = self.env['hr.employee.stats'].create({ 'employee_id': self.employee.id, 'date': start_date + timedelta(days=i), }) + stat._compute_dayofweek() stat._compute_hours() yield stat def test_invalide_recovery_type(self): - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière + start_date = date.today() - timedelta( + days=date.today().weekday() + 7 + ) # monday of last week self.recovery_type.request_unit = 'day' self.recovery_type.allows_negative = False timesheet_sheet = self._create_timesheet_sheet(start_date) @@ -74,56 +82,58 @@ class TestHrEmployeeStatsRecovery(TransactionCase): timesheet_sheet.action_generate_recovery_allocation() def test_no_recovery_hours(self): - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière + start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week timesheet_sheet = self._create_timesheet_sheet(start_date) - for stat in self._create_stats(start_date, 5, 7): #créer 5 stats de 7h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour - self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # l'employé a travaillé 7h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour - self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour - self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # pas de différence entre les heures travaillées et les heures planifiées - # La feuille de temps sur cette période doit compter 0h de déficit et 0h de récupération + for stat in self._create_stats(start_date, 5, 7): # create 5 stats of 7h each + # Compare calculated recovery hours and the calendar which plans 7h per day + self.assertEqual(stat.total_hours, 7, "total_hours should be 7",) # the employee worked 7h each day + self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day + self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day + self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, 0, "gap_hours should be 0",) # no difference between worked and planned hours + # The timesheet for this period should count 0 gap hours and 0 recovery hours self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 0, "timesheet_sheet_gap_hours should be 0",) self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 0, "timesheet_sheet_recovery_hours should be 0",) def test_positive_recovery_hours(self): - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière + start_date = date.today() - timedelta( + days=date.today().weekday() + 7 + ) # monday of last week timesheet_sheet = self._create_timesheet_sheet(start_date) - for stat in self._create_stats(start_date, 5, 8): #créer 5 stats de 8h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour - self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 8h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour - self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour - self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de plus que prévu - # La feuille de temps doit compter 5h d'heure sup soit 6,25h de récupération avec la majoration de 25% + for stat in self._create_stats(start_date, 5, 8): # create 5 stats of 8h each + # Compare calculated recovery hours and the calendar which plans 7h per day + self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # the employee worked 8h each day + self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day + self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day + self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned + # The timesheet should count 5 overtime hours = 6.25h recovery with 25% coefficient self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 5, "timesheet_sheet_gap_hours should be 5",) self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 6.25, "timesheet_sheet_recovery_hours should be 6,25",) - # générer l'allocation de récupération depuis la feuille de temps + # generate the recovery allocation from the timesheet timesheet_sheet.action_generate_recovery_allocation() recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") - self.assertEqual(recovery_allocation.number_of_days,0.78125, "The recovery allocation should be for 6.25h/8h = 0.78125 day") + self.assertEqual(recovery_allocation.number_of_days,0.8928571428571429, "The recovery allocation should be for 6.25h/7h = 0.8928571428571429 day") def test_negative_recovery_hours(self): - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière + start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week timesheet_sheet = self._create_timesheet_sheet(start_date) - for stat in self._create_stats(start_date, 5, 6): #créer 5 stats de 6h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour - self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # l'employé a travaillé 6h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour - self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour - self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # l'employée a travaillé une heure de moins que prévu - # La feuille de temps doit compter -5h de déficit et -5h de récupération - self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, -5, "timesheet_sheet_gap_hours should be -5",) # l'employé a travaillé -5h au total sur la semaine - self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, -5, "timesheet_sheet_recovery_hours should be -5",) # -5h sera le montant de l'allocation de récupération (pas de coef appliqué pour les déficites d'heures) - # générer l'allocation de récupération depuis la feuille de temps + for stat in self._create_stats(start_date, 5, 6): # create 5 stats of 6h each + # Compare calculated recovery hours and the calendar which plans 7h per day + self.assertEqual(stat.total_hours, 6, "total_hours should be 6",) # the employee worked 6h each day + self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day + self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day + self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, -1, "gap_hours should be -1",) # the employee worked one hour less than planned + # The timesheet should count -5 gap hours and -5 recovery hours + self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, -5, "timesheet_sheet_gap_hours should be -5",) # total gap hours for the week + self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, -5, "timesheet_sheet_recovery_hours should be -5",) # -5h will be the recovery allocation (no coef applied for deficits) + # generate the recovery allocation from the timesheet timesheet_sheet.action_generate_recovery_allocation() recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") - self.assertEqual(recovery_allocation.number_of_days, -0.625, "The recovery allocation should be for -5/8 hours = ") + self.assertEqual(recovery_allocation.number_of_days, -0.7142857142857143, "The recovery allocation should be for -5/7 hours = 0.7142857142857143 days") def test_recovery_hours_part_time_employee(self): part_time_calendar = self.env['resource.calendar'].create({ @@ -140,19 +150,18 @@ class TestHrEmployeeStatsRecovery(TransactionCase): ], }) self.employee.resource_calendar_id = part_time_calendar.id - - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière + start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week timesheet_sheet = self._create_timesheet_sheet(start_date) - for stat in self._create_stats(start_date, 4, 8): #créer 4 stats de 8h chacune - # Compare les heures de récupération calculées et le calendrier qui prévoit 7h par jour pendant 4 jours - self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # l'employé a travaillé 6h chaque jour - self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # le calendrier prévoit 7h chaque jour - self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # l'employé n'a pas de congé sur ce jour - self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # l'employé n'a pas posé de récupération sur ce jour - self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # l'employée a travaillé une heure de moins que prévu - # La feuille de temps doit compter 4h d'heure sup soit 5h de récupération avec la majoration de 25% - self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) # l'employé a travaillé supplémentaire 4h au total sur la semaine - self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h sera le montant de l'allocation de récupération (coef de 25% de majoration) + for stat in self._create_stats(start_date, 4, 8): # create 4 stats of 8h each + # Compare calculated recovery hours and the calendar which plans 7h per day for 4 days + self.assertEqual(stat.total_hours, 8, "total_hours should be 8",) # the employee worked 8h each day + self.assertEqual(stat.total_planned_hours, 7, "total_planned_hours should be 7",) # the calendar plans 7h each day + self.assertEqual(stat.total_leave_hours, 0, "total_leave_hours should be 0",) # the employee has no leave on this day + self.assertEqual(stat.total_recovery_hours, 0, "total_recovery_hours should be 0",) # the employee has no recovery on this day + self.assertEqual(stat.gap_hours, 1, "gap_hours should be 1",) # the employee worked one hour more than planned + # The timesheet should count 4 overtime hours = 5 recovery hours with 25% coefficient + self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) # total extra hours for the week + self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h will be the recovery allocation (25% coef) def test_recovery_hours_change_contract(self): part_time_calendar = self.env['resource.calendar'].create({ @@ -172,8 +181,8 @@ class TestHrEmployeeStatsRecovery(TransactionCase): self.env['hr.contract'].create({ 'name': 'Contract 1', 'employee_id': self.employee.id, - 'date_start': date.today() - timedelta(days=300), # date de début factice - 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière + 'date_start': date.today() - timedelta(days=300), # fake start date + 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # end date: wednesday of last week 'resource_calendar_id': self.base_calendar.id, 'wage': 2000, 'state': 'close', @@ -182,16 +191,16 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 'name': 'Contract 2', 'employee_id': self.employee.id, 'state': 'open', - 'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # date de début le jeudi de la semaine dernière + 'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # start date: thursday of last week 'date_end': False, 'resource_calendar_id': self.base_calendar.id, 'wage': 1500, }) self.employee.resource_calendar_id = part_time_calendar.id - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière - #create a timesheet with period including the change of contract + start_date = date.today() - timedelta(days=date.today().weekday() + 7) # monday of last week + # create a timesheet with period including the change of contract timesheet_sheet = self._create_timesheet_sheet(start_date) - #the create of recovery allocation should raise an error + # the creation of the recovery allocation should raise an error with self.assertRaises(UserError): timesheet_sheet.action_generate_recovery_allocation() @@ -242,9 +251,33 @@ class TestHrEmployeeStatsRecovery(TransactionCase): recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)]) self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") + def test_public_holiday(self): + # create a public holiday + self.env["resource.calendar.leaves"].create( + { + "name": "1 mai 2025", + "date_from": datetime(2025,4,30,22,0,0), + "date_to": datetime(2025,5,1,21,0,0), + } + ) + #create 5 stats of 7h each including the public holiday on 1st may + stats = self._create_stats(Date.to_date("2025-04-28"), 5, 7) + for stat in stats: + stat._compute_dayofweek() + stat._compute_hours() + #create 1 timesheet sheet from monday to friday including the public holiday on 1st may + timesheet_sheet = self.env['hr_timesheet.sheet'].create({ + 'employee_id': self.employee.id, + 'date_start': "2025-04-28", + 'date_end': "2025-05-04", + }) + self.assertEqual(timesheet_sheet.timesheet_sheet_gap_hours, 7, "timesheet_sheet_gap_hours should be 7",) + self.assertEqual(timesheet_sheet.timesheet_sheet_recovery_hours, 8.75, "timesheet_sheet_recovery_hours should be 8,75",) - - + timesheet_sheet.action_generate_recovery_allocation() + recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet.id)]) + self.assertEqual(len(recovery_allocation), 1, "Il doit y avoir une allocation de récupération générée") + self.assertEqual(recovery_allocation.number_of_days,1.25, "The recovery allocation should be 1,25 days") diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/.gitignore b/hr_negative_leave/.gitignore similarity index 100% rename from allow_negative_leave_and_allocation_hr_holidays_attendance/.gitignore rename to hr_negative_leave/.gitignore diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/LICENSE b/hr_negative_leave/LICENSE similarity index 100% rename from allow_negative_leave_and_allocation_hr_holidays_attendance/LICENSE rename to hr_negative_leave/LICENSE diff --git a/hr_negative_leave/README.rst b/hr_negative_leave/README.rst new file mode 100644 index 0000000..53656e0 --- /dev/null +++ b/hr_negative_leave/README.rst @@ -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 `_. 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 ` + +Funders +------- + +The development of this module has been financially supported by: +* Elabore (https://elabore.coop) + + +Maintainer +---------- + +This module is maintained by Elabore. \ No newline at end of file diff --git a/hr_negative_leave/__init__.py b/hr_negative_leave/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/hr_negative_leave/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/allow_negative_leave_and_allocation_hr_holidays_attendance/__manifest__.py b/hr_negative_leave/__manifest__.py similarity index 56% rename from allow_negative_leave_and_allocation_hr_holidays_attendance/__manifest__.py rename to hr_negative_leave/__manifest__.py index 4983002..fad16ff 100644 --- a/allow_negative_leave_and_allocation_hr_holidays_attendance/__manifest__.py +++ b/hr_negative_leave/__manifest__.py @@ -1,30 +1,28 @@ -# Copyright 2025 Elabore () +# Copyright 2024 Elabore () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { - "name": "allow_negative_leave_and_allocation_hr_holidays_attendance", - "version": "16.0.1.0.0", + "name": "hr_negative_leave", + "version": "16.0.3.0.0", "author": "Elabore", "website": "https://elabore.coop", "maintainer": "Elabore", "license": "AGPL-3", - "category": "HR", - "summary": "manage heritance of Duration in TimeOffCard", + "category": "hr", + "summary": "allow negative leaves, manage negative leave balances and negative allocations", # any module necessary for this one to work correctly "depends": [ - "base","allow_negative_leave_and_allocation","hr_holidays_attendance", + "base","hr_holidays", ], "qweb": [], "external_dependencies": { "python": [], }, # always loaded - "data": [], - "assets": { - 'web.assets_backend': [ - 'allow_negative_leave_and_allocation_hr_holidays_attendance/static/src/xml/time_off_card.xml', - ], - }, + "data": [ + "views/hr_leave_type_views.xml", + "views/hr_leave_views.xml", + ], # only loaded in demonstration mode "demo": [], "js": [], @@ -32,6 +30,6 @@ "installable": True, # Install this module automatically if all dependency have been previously # and independently installed. Used for synergetic or glue modules. - "auto_install": True, + "auto_install": False, "application": False, } \ No newline at end of file diff --git a/hr_negative_leave/i18n/fr.po b/hr_negative_leave/i18n/fr.po new file mode 100644 index 0000000..7b7b71a --- /dev/null +++ b/hr_negative_leave/i18n/fr.po @@ -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" diff --git a/hr_negative_leave/i18n/hr_negative_leave.pot b/hr_negative_leave/i18n/hr_negative_leave.pot new file mode 100644 index 0000000..5ce142b --- /dev/null +++ b/hr_negative_leave/i18n/hr_negative_leave.pot @@ -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 "" diff --git a/hr_negative_leave/models/__init__.py b/hr_negative_leave/models/__init__.py new file mode 100644 index 0000000..ed1f735 --- /dev/null +++ b/hr_negative_leave/models/__init__.py @@ -0,0 +1 @@ +from . import hr_leave_type, hr_leave \ No newline at end of file diff --git a/hr_negative_leave/models/hr_leave.py b/hr_negative_leave/models/hr_leave.py new file mode 100644 index 0000000..f7d1fb4 --- /dev/null +++ b/hr_negative_leave/models/hr_leave.py @@ -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() \ No newline at end of file diff --git a/hr_negative_leave/models/hr_leave_type.py b/hr_negative_leave/models/hr_leave_type.py new file mode 100644 index 0000000..93c13cf --- /dev/null +++ b/hr_negative_leave/models/hr_leave_type.py @@ -0,0 +1,241 @@ + +# -*- 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.") + + remaining_leaves_allowing_negative = fields.Float( + string="Remaining Leaves when Negative Allowed", + compute='_compute_remaining_leaves_allowing_negative', + ) + + def _compute_remaining_leaves_allowing_negative(self): + for holiday_type in self: + if holiday_type.allows_negative: + # if left != usable : remaining_leaves_allowing_negative = left + usable + if holiday_type.virtual_remaining_leaves < 0 (holiday_type.max_leaves - holiday_type.virtual_leaves_taken) != holiday_type.virtual_remaining_leaves: + holiday_type.remaining_leaves_allowing_negative = holiday_type.max_leaves - holiday_type.virtual_leaves_taken + holiday_type.virtual_remaining_leaves + else: + # else : remaining_leaves_allowing_negative = left as usual + holiday_type.remaining_leaves_allowing_negative = holiday_type.max_leaves - holiday_type.virtual_leaves_taken + else: + holiday_type.remaining_leaves_allowing_negative = None + + @api.depends('requires_allocation') + def _compute_valid(self): + res = super()._compute_valid() + for holiday_type in res: + if not holiday_type.has_valid_allocation: + holiday_type.has_valid_allocation = holiday_type.allows_negative + + #overwrite _get_employees_days_per_allocation() from hr_holidays module + def _get_employees_days_per_allocation(self, employee_ids, date=None): + if not date: + date = fields.Date.to_date(self.env.context.get('default_date_from')) or fields.Date.context_today(self) + + leaves_domain = [ + ('employee_id', 'in', employee_ids), + ('state', 'in', ['confirm', 'validate1', 'validate']), + ('holiday_status_id', 'in', self.ids) + ] + if self.env.context.get("ignore_future"): + leaves_domain.append(('date_from', '<=', date)) + leaves = self.env['hr.leave'].search(leaves_domain) + + allocations = self.env['hr.leave.allocation'].with_context(active_test=False).search([ + ('employee_id', 'in', employee_ids), + ('state', 'in', ['validate']), + ('holiday_status_id', 'in', self.ids), + ]) + + # The allocation_employees dictionary groups the allocations based on the employee and the holiday type + # The structure is the following: + # - KEYS: + # allocation_employees + # |--employee_id + # |--holiday_status_id + # - VALUES: + # Intervals with the start and end date of each allocation and associated allocations within this interval + allocation_employees = defaultdict(lambda: defaultdict(list)) + + ### Creation of the allocation intervals ### + for holiday_status_id in allocations.holiday_status_id: + for employee_id in employee_ids: + allocation_intervals = Intervals([( + fields.datetime.combine(allocation.date_from, time.min), + fields.datetime.combine(allocation.date_to or datetime.date.max, time.max), + allocation) + for allocation in allocations.filtered(lambda allocation: allocation.employee_id.id == employee_id and allocation.holiday_status_id == holiday_status_id)]) + + allocation_employees[employee_id][holiday_status_id] = allocation_intervals + + # The leave_employees dictionary groups the leavess based on the employee and the holiday type + # The structure is the following: + # - KEYS: + # leave_employees + # |--employee_id + # |--holiday_status_id + # - VALUES: + # Intervals with the start and end date of each leave and associated leave within this interval + leaves_employees = defaultdict(lambda: defaultdict(list)) + leave_intervals = [] + + ### Creation of the leave intervals ### + if leaves: + for holiday_status_id in leaves.holiday_status_id: + for employee_id in employee_ids: + leave_intervals = Intervals([( + fields.datetime.combine(leave.date_from, time.min), + fields.datetime.combine(leave.date_to, time.max), + leave) + for leave in leaves.filtered(lambda leave: leave.employee_id.id == employee_id and leave.holiday_status_id == holiday_status_id)]) + + leaves_employees[employee_id][holiday_status_id] = leave_intervals + + # allocation_days_consumed is a dictionary to map the number of days/hours of leaves taken per allocation + # The structure is the following: + # - KEYS: + # allocation_days_consumed + # |--employee_id + # |--holiday_status_id + # |--allocation + # |--virtual_leaves_taken + # |--leaves_taken + # |--virtual_remaining_leaves + # |--remaining_leaves + # |--max_leaves + # |--closest_allocation_to_expire + # - VALUES: + # Integer representing the number of (virtual) remaining leaves, (virtual) leaves taken or max leaves for each allocation. + # The unit is in hour or days depending on the leave type request unit + allocations_days_consumed = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: 0)))) + + company_domain = [('company_id', 'in', list(set(self.env.company.ids + self.env.context.get('allowed_company_ids', []))))] + + ### Existing leaves assigned to allocations ### + if leaves_employees: + for employee_id, leaves_interval_by_status in leaves_employees.items(): + for holiday_status_id in leaves_interval_by_status: + days_consumed = allocations_days_consumed[employee_id][holiday_status_id] + if allocation_employees[employee_id][holiday_status_id]: + allocations = allocation_employees[employee_id][holiday_status_id] & leaves_interval_by_status[holiday_status_id] + available_allocations = self.env['hr.leave.allocation'] + for allocation_interval in allocations._items: + available_allocations |= allocation_interval[2] + # Consume the allocations that are close to expiration first + sorted_available_allocations = available_allocations.filtered('date_to').sorted(key='date_to') + sorted_available_allocations += available_allocations.filtered(lambda allocation: not allocation.date_to) + leave_intervals = leaves_interval_by_status[holiday_status_id]._items + sorted_allocations_with_remaining_leaves = self.env['hr.leave.allocation'] + for leave_interval in leave_intervals: + leaves = leave_interval[2] + for leave in leaves: + if leave.leave_type_request_unit in ['day', 'half_day']: + leave_duration = leave.number_of_days + leave_unit = 'days' + else: + leave_duration = leave.number_of_hours_display + leave_unit = 'hours' + if holiday_status_id.requires_allocation != 'no': + for available_allocation in sorted_available_allocations: + # if the allocation is not valid for the leave period, continue + if (available_allocation.date_to and available_allocation.date_to < leave.date_from.date()) \ + or (available_allocation.date_from > leave.date_to.date()): + continue + # calculate the number of days/hours for this allocation (allocation days/hours - leaves already taken) + virtual_remaining_leaves = (available_allocation.number_of_days if leave_unit == 'days' else available_allocation.number_of_hours_display) - allocations_days_consumed[employee_id][holiday_status_id][available_allocation]['virtual_leaves_taken'] + ########################################### + # Modification for leaves allowing negative # + ########################################### + # if negative is allowed for this leave type, we can exceed the number of available days in this allocation + if holiday_status_id.allows_negative: + max_leaves = leave_duration + else: + # if negative is not allowed for this leave type, then we cannot exceed the allocation amount + # the max leaves for this allocation is the minimum between the remaining available days and the leave duration + max_leaves = min(virtual_remaining_leaves, leave_duration) + # the new calculation of days taken for this allocation is previous taken + max_leaves (which can never exceed the allocation total) + days_consumed[available_allocation]['virtual_leaves_taken'] += max_leaves + if leave.state == 'validate': + days_consumed[available_allocation]['leaves_taken'] += max_leaves + leave_duration -= max_leaves + # Check valid allocations with still availabe leaves on it + if days_consumed[available_allocation]['virtual_remaining_leaves'] > 0 and available_allocation.date_to and available_allocation.date_to > date: + sorted_allocations_with_remaining_leaves |= available_allocation + if leave_duration > 0: + # There are not enough allocation for the number of leaves + days_consumed[False]['virtual_remaining_leaves'] -= leave_duration + else: + days_consumed[False]['virtual_leaves_taken'] += leave_duration + if leave.state == 'validate': + days_consumed[False]['leaves_taken'] += leave_duration + # no need to sort the allocations again + allocations_days_consumed[employee_id][holiday_status_id][False]['closest_allocation_to_expire'] = sorted_allocations_with_remaining_leaves[0] if sorted_allocations_with_remaining_leaves else False + + # Future available leaves + future_allocations_date_from = fields.datetime.combine(date, time.min) + future_allocations_date_to = fields.datetime.combine(date, time.max) + timedelta(days=5*365) + for employee_id, allocation_intervals_by_status in allocation_employees.items(): + employee = self.env['hr.employee'].browse(employee_id) + for holiday_status_id, intervals in allocation_intervals_by_status.items(): + if not intervals: + continue + future_allocation_intervals = intervals & Intervals([( + future_allocations_date_from, + future_allocations_date_to, + self.env['hr.leave'])]) + search_date = date + closest_allocations = self.env['hr.leave.allocation'] + for interval in intervals._items: + closest_allocations |= interval[2] + allocations_with_remaining_leaves = self.env['hr.leave.allocation'] + for interval_from, interval_to, interval_allocations in future_allocation_intervals._items: + if interval_from.date() > search_date: + continue + interval_allocations = interval_allocations.filtered('active') + if not interval_allocations: + continue + # If no end date to the allocation, consider the number of days remaining as infinite + employee_quantity_available = ( + employee._get_work_days_data_batch(interval_from, interval_to, compute_leaves=False, domain=company_domain)[employee_id] + if interval_to != future_allocations_date_to + else {'days': float('inf'), 'hours': float('inf')} + ) + reached_remaining_days_limit = False + for allocation in interval_allocations: + if allocation.date_from > search_date: + continue + days_consumed = allocations_days_consumed[employee_id][holiday_status_id][allocation] + if allocation.type_request_unit in ['day', 'half_day']: + quantity_available = employee_quantity_available['days'] + remaining_days_allocation = (allocation.number_of_days - days_consumed['virtual_leaves_taken']) + else: + quantity_available = employee_quantity_available['hours'] + remaining_days_allocation = (allocation.number_of_hours_display - days_consumed['virtual_leaves_taken']) + #TODO leave allocation allowing negative not yet handled here + if quantity_available <= remaining_days_allocation: + search_date = interval_to.date() + timedelta(days=1) + days_consumed['max_leaves'] = allocation.number_of_days if allocation.type_request_unit in ['day', 'half_day'] else allocation.number_of_hours_display + if not reached_remaining_days_limit: + days_consumed['virtual_remaining_leaves'] += min(quantity_available, remaining_days_allocation) + days_consumed['remaining_leaves'] = days_consumed['max_leaves'] - days_consumed['leaves_taken'] + if remaining_days_allocation >= quantity_available: + reached_remaining_days_limit = True + # Check valid allocations with still availabe leaves on it + if days_consumed['virtual_remaining_leaves'] > 0 and allocation.date_to and allocation.date_to > date: + allocations_with_remaining_leaves |= allocation + allocations_sorted = sorted(allocations_with_remaining_leaves, key=lambda a: a.date_to) + allocations_days_consumed[employee_id][holiday_status_id][False]['closest_allocation_to_expire'] = allocations_sorted[0] if allocations_sorted else False + return allocations_days_consumed + diff --git a/hr_negative_leave/views/hr_leave_type_views.xml b/hr_negative_leave/views/hr_leave_type_views.xml new file mode 100644 index 0000000..412b821 --- /dev/null +++ b/hr_negative_leave/views/hr_leave_type_views.xml @@ -0,0 +1,18 @@ + + + + hr.leave.type.negative.leave + hr.leave.type + + + + + + + + + + \ No newline at end of file diff --git a/hr_negative_leave/views/hr_leave_views.xml b/hr_negative_leave/views/hr_leave_views.xml new file mode 100644 index 0000000..f9f0eec --- /dev/null +++ b/hr_negative_leave/views/hr_leave_views.xml @@ -0,0 +1,22 @@ + + + hr.leave.view.negative.leave + hr.leave + + + + [ + '|', + ('requires_allocation', '=', 'no'), + '&', + ('has_valid_allocation', '=', True), + '|', + ('allows_negative', '=', True), + '&', + ('virtual_remaining_leaves', '>', 0), + ('allows_negative', '=', False), + ] + + + + \ No newline at end of file diff --git a/hr_timesheet_sheet_usability/README.rst b/hr_timesheet_sheet_usability/README.rst index bf52f64..d891375 100644 --- a/hr_timesheet_sheet_usability/README.rst +++ b/hr_timesheet_sheet_usability/README.rst @@ -29,7 +29,7 @@ None yet. Bug Tracker =========== -Bugs are tracked on `our issues website `_. In case of +Bugs are tracked on `our issues website `_. 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.