diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 22e7a38a64b0..11cfc325a3a7 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -85,15 +85,23 @@ class ForecastLine(models.Model): "forecast.line", "employee_resource_forecast_line_id" ) + def _get_consumption_states(self): + consumption_states = self.env.company.forecast_consumption_states + return tuple(consumption_states.split("_")) + @api.depends("employee_id", "date_from", "type", "res_model") def _compute_employee_forecast_line_id(self): + consumption_states = self._get_consumption_states() employees = self.mapped("employee_id") + main_roles = employees.mapped("main_role_id") date_froms = self.mapped("date_from") date_tos = self.mapped("date_to") + forecast_roles = self.mapped("forecast_role_id") | main_roles if employees: lines = self.search( [ ("employee_id", "in", employees.ids), + ("forecast_role_id", "in", forecast_roles.ids), ("res_model", "=", "hr.employee.forecast.role"), ("date_from", ">=", min(date_froms)), ("date_to", "<=", max(date_tos)), @@ -104,12 +112,26 @@ def _compute_employee_forecast_line_id(self): lines = self.env["forecast.line"] capacities = {} for line in lines: - capacities[(line.employee_id.id, line.date_from)] = line.id + capacities[ + (line.employee_id.id, line.date_from, line.forecast_role_id.id) + ] = line.id for rec in self: - if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role": - rec.employee_resource_forecast_line_id = capacities.get( - (rec.employee_id.id, rec.date_from), False + if ( + rec.type in consumption_states + and rec.res_model != "hr.employee.forecast.role" + ): + resource_forecast_line = capacities.get( + (rec.employee_id.id, rec.date_from, rec.forecast_role_id.id), False ) + if resource_forecast_line: + rec.employee_resource_forecast_line_id = resource_forecast_line + else: + # if we didn't find a forecast line with a matching role + # we get forecast line with the main role of the employee + main_role_id = rec.employee_id.main_role_id + rec.employee_resource_forecast_line_id = capacities.get( + (rec.employee_id.id, rec.date_from, main_role_id.id), False + ) else: rec.employee_resource_forecast_line_id = False diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py index 7e4df82a15ef..0b3cb91d16c6 100644 --- a/project_forecast_line/models/project_task.py +++ b/project_forecast_line/models/project_task.py @@ -18,9 +18,9 @@ class ProjectTask(models.Model): def create(self, vals_list): # compatibility with fields from project_enterprise for vals in vals_list: - if "planned_date_begin" in vals: + if vals.get("planned_date_begin"): vals["forecast_date_planned_start"] = vals["planned_date_begin"] - if "planned_date_end" in vals: + if vals.get("planned_date_end"): vals["forecast_date_planned_end"] = vals["planned_date_end"] tasks = super().create(vals_list) tasks._update_forecast_lines() @@ -94,6 +94,7 @@ def _update_forecast_lines(self): # are not generating forecast lines from SO _logger.info("skip task %s: draft sale") continue + if ( not task.forecast_date_planned_start or not task.forecast_date_planned_end diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py index 4d88fee87d3a..c3a41c066129 100644 --- a/project_forecast_line/models/res_company.py +++ b/project_forecast_line/models/res_company.py @@ -14,6 +14,22 @@ class ResCompany(models.Model): forecast_line_horizon = fields.Integer( help="Number of month for the forecast planning", default=12 ) + forecast_consumption_states = fields.Selection( + selection=[ + ("confirmed", "Compute consolidated forecast for lines of type confirmed"), + ( + "forecast_confirmed", + "Include lines of type forecast in consolidated forecast computation", + ), + ], + string="Consumption state rules", + help="For instance, holidays requests and sales quotation lines" + "create lines of type forecast and won't be taken into account" + "during consolidated forecast computation, whereas tasks for project" + "which are in a running state create lines with type confirmed" + "and will be used to compute consolidated forecast.", + default="confirmed", + ) def write(self, values): res = super().write(values) diff --git a/project_forecast_line/models/res_config_settings.py b/project_forecast_line/models/res_config_settings.py index 4ac43649ec64..4c7cc90534fe 100644 --- a/project_forecast_line/models/res_config_settings.py +++ b/project_forecast_line/models/res_config_settings.py @@ -12,7 +12,9 @@ class ResConfigSettings(models.TransientModel): forecast_line_horizon = fields.Integer( related="company_id.forecast_line_horizon", readonly=False ) - + forecast_consumption_states = fields.Selection( + related="company_id.forecast_consumption_states", readonly=False + ) group_forecast_line_on_quotation = fields.Boolean( "Forecast Line on Quotations", implied_group="project_forecast_line.group_forecast_line_on_quotation", diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index 9a1a6a9d98fc..34fbb1bfd341 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -517,7 +517,7 @@ def test_task_forecast_lines_consolidated_forecast(self): self.assertEqual(len(forecast), 1) # using assertEqual on purpose here self.assertEqual(forecast.forecast_hours, -6.0) - self.assertEqual(round(forecast.consolidated_forecast, 5), 0.75000) + self.assertAlmostEqual(forecast.consolidated_forecast, 0.75) self.assertEqual( forecast.employee_resource_forecast_line_id.consolidated_forecast, 0.25, @@ -603,10 +603,148 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks forecast1.employee_resource_forecast_line_id, forecast2.employee_resource_forecast_line_id, ) - self.assertEqual( - round( - forecast1.employee_resource_forecast_line_id.consolidated_forecast, - 5, - ), - -0.75000, + self.assertAlmostEqual( + forecast1.employee_resource_forecast_line_id.consolidated_forecast, + -0.75, ) + + def test_task_forecast_lines_employee_different_roles(self): + """ + Test forecast lines when employee has different roles. + + Employee has 2 forecast_role_id: consultant 75% and project manager 25%, + working 8h per day (standard calendar). + Create a task with forecast role consultant, with remaining time = 8h + and a scheduled period starting and ending on the same day (today for instance). + Assign this task to the user. + + Expected: for the user, on today, 3 forecast lines. + + res_model forecast_role_id forecast_hours consolidated_forecast + project.task consultant -8 1 (in days) + hr.employee.forecast.role consultant 6 -0.25 (in days) + hr.employee.forecast.role project manager 2 0.25 (in days) + + """ + self.env["hr.employee.forecast.role"].create( + { + "employee_id": self.employee_consultant.id, + "role_id": self.role_pm.id, + "date_start": "2022-01-01", + "rate": 25, + "sequence": 1, + } + ) + consultant_role = self.env["hr.employee.forecast.role"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("role_id", "=", self.role_consultant.id), + ] + ) + consultant_role.rate = 75 + project = self.env["project.project"].create({"name": "TestProjectDiffRoles"}) + # set project in stage "in progress" to get confirmed forecast + project.stage_id = self.env.ref("project.project_project_stage_1") + task = self.env["project.task"].create( + { + "name": "TaskDiffRoles", + "project_id": project.id, + "forecast_role_id": self.role_consultant.id, + "forecast_date_planned_start": date.today(), + "forecast_date_planned_end": date.today(), + "planned_hours": 8, + } + ) + task.user_ids = self.user_consultant + task_forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) + self.assertEqual(len(task_forecast), 1) + # using assertEqual on purpose here + self.assertEqual(task_forecast.forecast_hours, -8.0) + self.assertEqual(task_forecast.consolidated_forecast, 1.0) + employee_forecast = self.env["forecast.line"].search( + [("employee_id", "=", self.employee_consultant.id)] + ) + # we can take first line to check as forecast values are equal + forecast_consultant = employee_forecast.filtered( + lambda l: l.res_model == "hr.employee.forecast.role" + and l.forecast_role_id == self.role_consultant + )[0] + self.assertEqual(forecast_consultant.forecast_hours, 6.0) + self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25) + forecast_pm = employee_forecast.filtered( + lambda l: l.res_model == "hr.employee.forecast.role" + and l.forecast_role_id == self.role_pm + )[0] + self.assertEqual(forecast_pm.forecast_hours, 2.0) + self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) + + def test_task_forecast_lines_employee_main_role(self): + """ + Test forecast lines when employee has different roles + and different from employee's role is assigned to the task. + + Employee has 2 forecast_role_id: consultant 75% and project manager 25%, + working 8h per day (standard calendar). + Create a task with forecast role developer, with remaining time = 8h + and a scheduled period starting and ending on the same day (today for instance). + Assign this task to the user. + + Expected: for the user, on today, 3 forecast lines. + + res_model forecast_role_id forecast_hours consolidated_forecast + project.task consultant -8 1 (in days) + hr.employee.forecast.role consultant 6 -0.25 (in days) + hr.employee.forecast.role project manager 2 0.25 (in days) + + """ + self.env["hr.employee.forecast.role"].create( + { + "employee_id": self.employee_consultant.id, + "role_id": self.role_pm.id, + "date_start": "2022-01-01", + "rate": 25, + "sequence": 1, + } + ) + consultant_role = self.env["hr.employee.forecast.role"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("role_id", "=", self.role_consultant.id), + ] + ) + consultant_role.rate = 75 + project = self.env["project.project"].create({"name": "TestProjectDiffRoles"}) + # set project in stage "in progress" to get confirmed forecast + project.stage_id = self.env.ref("project.project_project_stage_1") + task = self.env["project.task"].create( + { + "name": "TaskDiffRoles", + "project_id": project.id, + "forecast_role_id": self.role_developer.id, + "forecast_date_planned_start": date.today(), + "forecast_date_planned_end": date.today(), + "planned_hours": 8, + } + ) + task.user_ids = self.user_consultant + task_forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) + self.assertEqual(len(task_forecast), 1) + # using assertEqual on purpose here + self.assertEqual(task_forecast.forecast_hours, -8.0) + self.assertEqual(task_forecast.consolidated_forecast, 1.0) + employee_forecast = self.env["forecast.line"].search( + [("employee_id", "=", self.employee_consultant.id)] + ) + # we can take first line to check as forecast values are equal + forecast_consultant = employee_forecast.filtered( + lambda l: l.res_model == "hr.employee.forecast.role" + and l.forecast_role_id == self.role_consultant + )[0] + self.assertEqual(forecast_consultant.forecast_hours, 6.0) + self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25) + forecast_pm = employee_forecast.filtered( + lambda l: l.res_model == "hr.employee.forecast.role" + and l.forecast_role_id == self.role_pm + )[0] + self.assertEqual(forecast_pm.forecast_hours, 2.0) + self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) diff --git a/project_forecast_line/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml index e273587afbd1..61b15942a2c3 100644 --- a/project_forecast_line/views/res_config_settings_views.xml +++ b/project_forecast_line/views/res_config_settings_views.xml @@ -50,6 +50,17 @@ class="o_field_integer o_field_number o_field_widget o_input oe_inline col-lg-2" /> +