diff --git a/hr_employee_stats_sheet/__manifest__.py b/hr_employee_stats_sheet/__manifest__.py index a00d679..11208e3 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.1.1", + "version": "16.0.3.0.0", "description": "Add global sheet for employee stats", "summary": "Add global sheet for employee stats", "author": "Nicolas JEUDY", @@ -15,6 +15,7 @@ "hr_timesheet", "hr_timesheet_sheet", "resource", + "hr_employee_calendar_planning", "hr_timesheet_sheet_usability_misc", "hr_timesheet_sheet_usability_akretion", ], diff --git a/hr_employee_stats_sheet/models/__init__.py b/hr_employee_stats_sheet/models/__init__.py index b2348e6..a50305a 100644 --- a/hr_employee_stats_sheet/models/__init__.py +++ b/hr_employee_stats_sheet/models/__init__.py @@ -2,4 +2,5 @@ from . import hr_employee_stats from . import hr_timesheet_sheet from . import res_config from . import res_company -from . import hr_leave_allocation \ No newline at end of file +from . import hr_leave_allocation +from . import hr_employee \ No newline at end of file diff --git a/hr_employee_stats_sheet/models/hr_employee.py b/hr_employee_stats_sheet/models/hr_employee.py new file mode 100644 index 0000000..8de452f --- /dev/null +++ b/hr_employee_stats_sheet/models/hr_employee.py @@ -0,0 +1,41 @@ +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 no hr_employee_calendar found, use employee resource_calendar_id + elif self.resource_calendar_id: + return self.resource_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 \ No newline at end of file diff --git a/hr_employee_stats_sheet/models/hr_employee_stats.py b/hr_employee_stats_sheet/models/hr_employee_stats.py index e03f908..28b525d 100644 --- a/hr_employee_stats_sheet/models/hr_employee_stats.py +++ b/hr_employee_stats_sheet/models/hr_employee_stats.py @@ -88,7 +88,7 @@ class HrEmployeeStats(models.Model): total_planned_hours = 0 if self.employee_id and self.date and not self.is_public_holiday: 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 if calendar_id.two_weeks_calendar: hours = calendar_id.attendance_ids.search( diff --git a/hr_employee_stats_sheet/models/hr_timesheet_sheet.py b/hr_employee_stats_sheet/models/hr_timesheet_sheet.py index 867a0e1..fc4df64 100644 --- a/hr_employee_stats_sheet/models/hr_timesheet_sheet.py +++ b/hr_employee_stats_sheet/models/hr_timesheet_sheet.py @@ -100,43 +100,6 @@ class HrTimesheetSheet(models.Model): sheet.recovery_allocation_ids.write({"state": "refuse"}) return res - def _get_contracts_in_progress_during_timesheet_sheet_time_period(self): - """ - get the contracts which was in progress during the timesheet sheet range time - """ - contracts = self.env["hr.contract"].search( - [ - ("employee_id", "=", self.employee_id.id), - ("state", "in", ("open", "close")), - ("date_start", "<=", self.date_end), - "|", - ("date_end", "=", False), # pas de date de fin OU - ("date_end", ">=", self.date_start), # date de fin après le début - ], - ) - return contracts - - def _get_calendar_in_progress_during_timesheet_time_period(self): - """ - get the ressource calendar which was used during the timesheet sheet time period - """ - #checks if only one contract runs over the duration of the timesheet - contracts = self._get_contracts_in_progress_during_timesheet_sheet_time_period() - if len(contracts) > 1: - # check if a new contract start during timesheet sheet time period. If yes, raise an error - raise UserError( - _("There is a contract starting during the timesheet sheet time period for the employee %s" - "Please create a new timesheet sheet starting from the new contract start date") - % self.employee_id.display_name - ) - # get the ressource calendar id according to the work contract - elif self.employee_id.resource_calendar_id: - return self.employee_id.resource_calendar_id - #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: @@ -147,7 +110,7 @@ class HrTimesheetSheet(models.Model): :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() + ressource_calendar_id = self.employee_id._get_calendar_in_progress_during_a_time_period(self.date_start,self.date_end) if ressource_calendar_id: resource_calendar_attendance_ids = self.env[ "resource.calendar.attendance" @@ -169,7 +132,7 @@ class HrTimesheetSheet(models.Model): :return: hours per day """ # get ressource calendar id used during the timesheet sheet time period - ressource_calendar_id = self._get_calendar_in_progress_during_timesheet_time_period() + ressource_calendar_id = self.employee_id._get_calendar_in_progress_during_a_time_period(self.date_start,self.date_end) if ressource_calendar_id: return ressource_calendar_id.hours_per_day return HOURS_PER_DAY diff --git a/hr_employee_stats_sheet/tests/test_employee_stats.py b/hr_employee_stats_sheet/tests/test_employee_stats.py index ef94f46..dc7b237 100644 --- a/hr_employee_stats_sheet/tests/test_employee_stats.py +++ b/hr_employee_stats_sheet/tests/test_employee_stats.py @@ -37,11 +37,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, @@ -49,7 +51,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, @@ -58,16 +60,17 @@ 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) @@ -76,7 +79,7 @@ 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 @@ -106,7 +109,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 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 @@ -125,7 +128,7 @@ class TestHrEmployeeStatsRecovery(TransactionCase): 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,714285714") def test_recovery_hours_part_time_employee(self): part_time_calendar = self.env['resource.calendar'].create({ @@ -156,8 +159,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_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) # 5h sera le montant de l'allocation de récupération (coef de 25% de majoration) - def test_recovery_hours_change_contract(self): - part_time_calendar = self.env['resource.calendar'].create({ + def test_recovery_hours_change_calendar(self): + 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', 'attendance_ids': [ (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), @@ -170,35 +174,31 @@ class TestHrEmployeeStatsRecovery(TransactionCase): (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), ], }) - #create one contract ending on wednesday and one other starting on thursday - self.env['hr.contract'].create({ - 'name': 'Contract 1', + + #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.today() - timedelta(days=300), # date de début factice - 'date_end': date.today() - timedelta(days= date.today().weekday() + 5), # date de fin le mercredi de la semaine dernière - 'resource_calendar_id': self.base_calendar.id, - 'wage': 2000, - 'state': 'close', + '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.contract'].create({ - 'name': 'Contract 2', + self.env['hr.employee.calendar'].create({ 'employee_id': self.employee.id, - 'state': 'open', - 'date_start': date.today() - timedelta(days= date.today().weekday() + 4), # date de début le jeudi de la semaine dernière - 'date_end': False, - 'resource_calendar_id': self.base_calendar.id, - 'wage': 1500, + 'date_start': Date.to_date("2025-08-01"), + 'date_end': None, + 'calendar_id': employee_part_time_calendar.id, }) - self.employee.resource_calendar_id = part_time_calendar.id - start_date = date.today() - timedelta(days=date.today().weekday() + 7) # lundi de la semaine dernière - #create a timesheet with period including the change of contract - timesheet_sheet = self._create_timesheet_sheet(start_date) + self.employee.resource_calendar_id = employee_part_time_calendar.id + + #create recovery hours on a period including the change of calendar + 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 with self.assertRaises(UserError): timesheet_sheet.action_generate_recovery_allocation() - def test_recovery_hours_change_contract_sucess(self): - part_time_calendar = self.env['resource.calendar'].create({ + def test_recovery_hours_change_calendar_sucess(self): + 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', 'attendance_ids': [ (0, 0, {'name': 'Monday Morning', 'dayofweek': '0', 'hour_from': 9, 'hour_to': 12, 'day_period': 'morning'}), @@ -211,38 +211,120 @@ class TestHrEmployeeStatsRecovery(TransactionCase): (0, 0, {'name': 'Thursday Afternoon', 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17, 'day_period': 'afternoon'}), ], }) - #create one contract ending on wednesday and one other starting on thursday - self.env['hr.contract'].create({ - 'name': 'Contract 1', + + #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(2025,8,18), - 'date_end': date(2025,8,24), - 'resource_calendar_id': self.base_calendar.id, - 'wage': 2000, - 'state': 'close', + '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.contract'].create({ - 'name': 'Contract 2', + self.env['hr.employee.calendar'].create({ 'employee_id': self.employee.id, - 'state': 'open', - 'date_start': date(2025,8,25), - 'date_end': date(2025,8,31), - 'resource_calendar_id': part_time_calendar.id, - 'wage': 1500, + 'date_start': Date.to_date("2025-08-01"), + 'date_end': None, + 'calendar_id': employee_part_time_calendar.id, }) - self.employee.resource_calendar_id = part_time_calendar.id - #create a timesheet with period including the change of contract - timesheet_sheet_1 = self._create_timesheet_sheet(date(2025,8,18)) - timesheet_sheet_2 = self._create_timesheet_sheet(date(2025,8,25)) - + self.employee.resource_calendar_id = employee_part_time_calendar.id + + #create stats during period of full time calendar for the employee + 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() + 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() - - recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_1.id)]) - self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") - - recovery_allocation = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)]) - self.assertEqual(len(recovery_allocation), 1, "There should be one recovery") + self.assertEqual(timesheet_sheet_2.timesheet_sheet_gap_hours, 4, "timesheet_sheet_gap_hours should be 4",) + self.assertEqual(timesheet_sheet_2.timesheet_sheet_recovery_hours, 5, "timesheet_sheet_recovery_hours should be 5",) + allocation_2 = self.env["hr.leave.allocation"].search([("timesheet_sheet_id","=",timesheet_sheet_2.id)]) + self.assertEqual(len(allocation_2), 1, "There should be one recovery") + self.assertEqual(allocation_2.number_of_days,0.5555555555555556, "The recovery allocation should be for 0,555555556 (5/9) day") + #check that allocation_1 hasn't changed + self.assertEqual(allocation_1.number_of_days,0.8928571428571429, "The recovery allocation should be for 0,892857143 day") def test_public_holiday(self): # create a public holiday