From ecb88c60be91fc95095b06eb1cfb04d196b24341 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Mon, 18 Jul 2022 12:32:47 +0300 Subject: [PATCH 01/33] [15.0][ADD] project_forecast_line --- project_forecast_line/README.rst | 1 + project_forecast_line/__init__.py | 1 + project_forecast_line/__manifest__.py | 28 + project_forecast_line/data/ir_cron.xml | 14 + project_forecast_line/data/project_data.xml | 19 + project_forecast_line/i18n/fr.po | 503 ++++++++++++++ .../i18n/project_forecast_line.pot | 470 ++++++++++++++ project_forecast_line/models/__init__.py | 14 + .../models/account_analytic_line.py | 18 + project_forecast_line/models/forecast_line.py | 322 +++++++++ project_forecast_line/models/forecast_role.py | 11 + project_forecast_line/models/hr_employee.py | 148 +++++ project_forecast_line/models/hr_leave.py | 78 +++ .../models/product_template.py | 9 + .../models/project_project.py | 18 + .../models/project_project_stage.py | 21 + project_forecast_line/models/project_task.py | 155 +++++ project_forecast_line/models/res_company.py | 25 + .../models/res_config_settings.py | 19 + .../models/resource_calendar_leaves.py | 32 + project_forecast_line/models/sale_order.py | 32 + .../models/sale_order_line.py | 137 ++++ project_forecast_line/readme/CONTRIBUTORS.rst | 2 + project_forecast_line/readme/DESCRIPTION.rst | 3 + .../security/forecast_line_security.xml | 13 + .../security/ir.model.access.csv | 6 + .../static/description/icon.png | Bin 0 -> 4738 bytes .../static/description/index.html | 423 ++++++++++++ project_forecast_line/tests/__init__.py | 1 + .../tests/test_forecast_line.py | 612 ++++++++++++++++++ .../views/forecast_line_views.xml | 154 +++++ .../views/forecast_role_views.xml | 48 ++ .../views/hr_employee_views.xml | 32 + project_forecast_line/views/product_views.xml | 12 + .../views/project_project_stage_views.xml | 34 + .../views/project_task_views.xml | 16 + .../views/res_config_settings_views.xml | 78 +++ .../views/sale_order_views.xml | 48 ++ 38 files changed, 3557 insertions(+) create mode 100644 project_forecast_line/README.rst create mode 100644 project_forecast_line/__init__.py create mode 100644 project_forecast_line/__manifest__.py create mode 100644 project_forecast_line/data/ir_cron.xml create mode 100644 project_forecast_line/data/project_data.xml create mode 100644 project_forecast_line/i18n/fr.po create mode 100644 project_forecast_line/i18n/project_forecast_line.pot create mode 100644 project_forecast_line/models/__init__.py create mode 100644 project_forecast_line/models/account_analytic_line.py create mode 100644 project_forecast_line/models/forecast_line.py create mode 100644 project_forecast_line/models/forecast_role.py create mode 100644 project_forecast_line/models/hr_employee.py create mode 100644 project_forecast_line/models/hr_leave.py create mode 100644 project_forecast_line/models/product_template.py create mode 100644 project_forecast_line/models/project_project.py create mode 100644 project_forecast_line/models/project_project_stage.py create mode 100644 project_forecast_line/models/project_task.py create mode 100644 project_forecast_line/models/res_company.py create mode 100644 project_forecast_line/models/res_config_settings.py create mode 100644 project_forecast_line/models/resource_calendar_leaves.py create mode 100644 project_forecast_line/models/sale_order.py create mode 100644 project_forecast_line/models/sale_order_line.py create mode 100644 project_forecast_line/readme/CONTRIBUTORS.rst create mode 100644 project_forecast_line/readme/DESCRIPTION.rst create mode 100644 project_forecast_line/security/forecast_line_security.xml create mode 100644 project_forecast_line/security/ir.model.access.csv create mode 100644 project_forecast_line/static/description/icon.png create mode 100644 project_forecast_line/static/description/index.html create mode 100644 project_forecast_line/tests/__init__.py create mode 100644 project_forecast_line/tests/test_forecast_line.py create mode 100644 project_forecast_line/views/forecast_line_views.xml create mode 100644 project_forecast_line/views/forecast_role_views.xml create mode 100644 project_forecast_line/views/hr_employee_views.xml create mode 100644 project_forecast_line/views/product_views.xml create mode 100644 project_forecast_line/views/project_project_stage_views.xml create mode 100644 project_forecast_line/views/project_task_views.xml create mode 100644 project_forecast_line/views/res_config_settings_views.xml create mode 100644 project_forecast_line/views/sale_order_views.xml diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst new file mode 100644 index 0000000000..c275fb4dbf --- /dev/null +++ b/project_forecast_line/README.rst @@ -0,0 +1 @@ +TO BE GENERATED BY OCA BOT diff --git a/project_forecast_line/__init__.py b/project_forecast_line/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/project_forecast_line/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py new file mode 100644 index 0000000000..de8349b15e --- /dev/null +++ b/project_forecast_line/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Project Forecast Lines", + "summary": "Project Forecast Lines", + "version": "15.0.1.0.0", + "author": "Camptocamp SA, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Project", + "website": "https://github.com/OCA/project", + "depends": ["sale_timesheet", "sale_project", "hr_holidays"], + "data": [ + "security/forecast_line_security.xml", + "security/ir.model.access.csv", + "views/sale_order_views.xml", + "views/hr_employee_views.xml", + "views/forecast_line_views.xml", + "views/forecast_role_views.xml", + "views/product_views.xml", + "views/project_task_views.xml", + "views/project_project_stage_views.xml", + "views/res_config_settings_views.xml", + "data/ir_cron.xml", + "data/project_data.xml", + ], + "installable": True, + "application": True, +} diff --git a/project_forecast_line/data/ir_cron.xml b/project_forecast_line/data/ir_cron.xml new file mode 100644 index 0000000000..21563c6143 --- /dev/null +++ b/project_forecast_line/data/ir_cron.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="cron_forecast_lines" model="ir.cron"> + <field name="name">Forecast recomputation</field> + <field name="model_id" ref="project_forecast_line.model_forecast_line" /> + <field name="user_id" ref="base.user_root" /> + <field name="state">code</field> + <field name="code">model._cron_recompute_all()</field> + <field name="interval_number">1</field> + <field name="interval_type">days</field> + <field name="numbercall">-1</field> + <field name="doall" eval="False" /> + </record> +</odoo> diff --git a/project_forecast_line/data/project_data.xml b/project_forecast_line/data/project_data.xml new file mode 100644 index 0000000000..1dd857c7af --- /dev/null +++ b/project_forecast_line/data/project_data.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <!-- Project Stages --> + <record id="project.project_project_stage_0" model="project.project.stage"> + <field name="forecast_line_type">forecast</field> + </record> + + <record id="project.project_project_stage_1" model="project.project.stage"> + <field name="forecast_line_type">confirmed</field> + </record> + + <record id="project.project_project_stage_2" model="project.project.stage"> + <field name="forecast_line_type" /> + </record> + + <record id="project.project_project_stage_3" model="project.project.stage"> + <field name="forecast_line_type" /> + </record> +</odoo> diff --git a/project_forecast_line/i18n/fr.po b/project_forecast_line/i18n/fr.po new file mode 100644 index 0000000000..3da256fe38 --- /dev/null +++ b/project_forecast_line/i18n/fr.po @@ -0,0 +1,503 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_forecast_line +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-09 11:54+0000\n" +"PO-Revision-Date: 2021-09-10 14:59+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Allow to see forecast dates on quotations" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_account_analytic_line +msgid "Analytic Line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_res_company +msgid "Companies" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__company_id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__company_id +msgid "Company" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: project_forecast_line +#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_config +msgid "Configuration" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__confirmed +#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__confirmed +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Confirmed" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__consolidated_forecast +msgid "Consolidated Forecast" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost +msgid "Cost" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__cost +msgid "" +"Cost, in company currency. Cost is positive for things which add forecast, " +"such as employees and negative for things which consume forecast such as " +"holidays, sales, or tasks. " +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_uid +msgid "Created by" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_date +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_date +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_date +msgid "Created on" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__currency_id +msgid "Currency" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_end +msgid "Date End" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_from +msgid "Date From" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_start +msgid "Date Start" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_to +msgid "Date To" +msgstr "" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Date from" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__date_from +msgid "Date of the period start for this line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__day +msgid "Day" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_end +msgid "Default Forecast Date End" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_start +msgid "Default Forecast Date Start" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__description +msgid "Description" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__display_name +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__display_name +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_employee +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__employee_id +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Employee" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_forecast_role_id +msgid "Employee Forecast Role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_consumption_ids +msgid "Employee Resource Consumption" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id +msgid "Employee Resource Forecast Line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_employee_forecast_role +msgid "Employee forecast role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_forecast_role +msgid "Employee role for task matching" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines +#: model:ir.model,name:project_forecast_line.model_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_hours +#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__forecast +#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__forecast +#: model:ir.ui.menu,name:project_forecast_line.forecast_menu_root +#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_line_consolidated +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Forecast" +msgstr "Plan de charge" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines_consolidated +msgid "Forecast (Consolidated)" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__forecast_hours +msgid "" +"Forecast (in hours). Forecast is positive for resources which add forecast, " +"such as employees, and negative for things which consume forecast, such as " +"holidays, sales, or tasks." +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_end +msgid "Forecast Date End" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_start +msgid "Forecast Date Start" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_granularity +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_granularity +msgid "Forecast Line Granularity" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_horizon +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_horizon +msgid "Forecast Line Horizon" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_project_project_stage__forecast_line_type +msgid "Forecast Line Type" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__group_forecast_line_on_quotation +msgid "Forecast Line on Quotations" +msgstr "" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Forecast Management" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_product_product__forecast_role_id +#: model:ir.model.fields,field_description:project_forecast_line.field_product_template__forecast_role_id +#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_role_id +msgid "Forecast Role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_roles +#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_role +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_employee_form +msgid "Forecast Roles" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.server,name:project_forecast_line.cron_forecast_lines_ir_actions_server +#: model:ir.cron,cron_name:project_forecast_line.cron_forecast_lines +#: model:ir.cron,name:project_forecast_line.cron_forecast_lines +msgid "Forecast recomputation" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_role_id +msgid "Forecast role" +msgstr "" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Group By" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__id +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__id +msgid "ID" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_job +msgid "Job Position" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line____last_update +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role____last_update +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_date +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_date +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_date +#, fuzzy +msgid "Last Updated on" +msgstr "Dernière modification le" + +#. module: project_forecast_line +#: code:addons/project_forecast_line/models/hr_leave.py:0 +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__hr_leave_id +#, python-format +msgid "Leave" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__main_role_id +msgid "Main Role" +msgstr "" + +#. module: project_forecast_line +#: model:res.groups,name:project_forecast_line.group_forecast_line_on_quotation +msgid "Manage Forecast Dates on Quotations" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_model +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Model" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__month +msgid "Month" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__name +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__name +msgid "Name" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_horizon +#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_horizon +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Number of month for the forecast planning" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_granularity +#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_granularity +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Periodicity of the forecast that will be generated" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_end +msgid "Planned end date" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_start +msgid "Planned start date" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_product_template +msgid "Product Template" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_project_project +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__project_id +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Project" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_project_project_stage +msgid "Project Stage" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__rate +msgid "Rate" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_id +msgid "Record ID" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_resource_calendar_leaves +msgid "Resource Time Off Detail" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__role_ids +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__role_id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_job__role_id +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_id +msgid "Sale" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_line_id +#, fuzzy +msgid "Sale line" +msgstr "Bon de commande" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_sale_order +msgid "Sales Order" +msgstr "Bon de commande" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_sale_order_line +#, fuzzy +msgid "Sales Order Line" +msgstr "Bon de commande" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence +msgid "Sequence" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.forecast_config_settings_action +#: model:ir.ui.menu,name:project_forecast_line.forecast_config_settings_menu_action +msgid "Settings" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_project_task +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__task_id +msgid "Task" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_leave +msgid "Time Off" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__type +msgid "Type" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__week +msgid "Week" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id +msgid "" +"technical field giving the name of the resource (model=hr.employee.forecast." +"role) line for that employee and that period" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_project_project_stage__forecast_line_type +msgid "type of forecast lines created by the tasks of projects in that stage" +msgstr "" + +#~ msgid "Indicate if a signed contract has been received for this SO" +#~ msgstr "Indique si un contrat signé a été reçu pour cette commande" + +#~ msgid "Log reception of signed contract" +#~ msgstr "Enregistrer la réception d'un contrat signé" + +#~ msgid "Missing contract" +#~ msgstr "Contrat manquant" + +#~ msgid "Record when the SO has (most recently) been marked as signed " +#~ msgstr "" +#~ "Enregistre quand la commande a été marquée comme signée (le plus " +#~ "récemment)" + +#~ msgid "Signed Date" +#~ msgstr "Date de signature" + +#~ msgid "Signed Status" +#~ msgstr "Statut signature" + +#~ msgid "" +#~ "You are not allowed to change the signed status of Sales Orders.\n" +#~ "Only members of the 'Log reception of signed contract' group can." +#~ msgstr "" +#~ "Vous n'êtes pas autorisé à changer le statut de signature des commandes " +#~ "de vente.\n" +#~ "Seuls les membres du goupe 'Enregistrer la réception d'un contrat signé' " +#~ "le peuvent." diff --git a/project_forecast_line/i18n/project_forecast_line.pot b/project_forecast_line/i18n/project_forecast_line.pot new file mode 100644 index 0000000000..3f97478e10 --- /dev/null +++ b/project_forecast_line/i18n/project_forecast_line.pot @@ -0,0 +1,470 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_forecast_line +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-08-09 11:54+0000\n" +"PO-Revision-Date: 2022-08-09 11:54+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: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Allow to see forecast dates on quotations" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_account_analytic_line +msgid "Analytic Line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_res_company +msgid "Companies" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__company_id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__company_id +msgid "Company" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: project_forecast_line +#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_config +msgid "Configuration" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__confirmed +#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__confirmed +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Confirmed" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__consolidated_forecast +msgid "Consolidated Forecast" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost +msgid "Cost" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__cost +msgid "" +"Cost, in company currency. Cost is positive for things which add forecast, " +"such as employees and negative for things which consume forecast such as " +"holidays, sales, or tasks. " +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_uid +msgid "Created by" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_date +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_date +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_date +msgid "Created on" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__currency_id +msgid "Currency" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_end +msgid "Date End" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_from +msgid "Date From" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_start +msgid "Date Start" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_to +msgid "Date To" +msgstr "" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Date from" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__date_from +msgid "Date of the period start for this line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__day +msgid "Day" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_end +msgid "Default Forecast Date End" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_start +msgid "Default Forecast Date Start" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__description +msgid "Description" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__display_name +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__display_name +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__display_name +msgid "Display Name" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_employee +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__employee_id +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Employee" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_forecast_role_id +msgid "Employee Forecast Role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_consumption_ids +msgid "Employee Resource Consumption" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id +msgid "Employee Resource Forecast Line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_employee_forecast_role +msgid "Employee forecast role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_forecast_role +msgid "Employee role for task matching" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines +#: model:ir.model,name:project_forecast_line.model_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_hours +#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__forecast +#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__forecast +#: model:ir.ui.menu,name:project_forecast_line.forecast_menu_root +#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_line_consolidated +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Forecast" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines_consolidated +msgid "Forecast (Consolidated)" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__forecast_hours +msgid "" +"Forecast (in hours). Forecast is positive for resources which add forecast, " +"such as employees, and negative for things which consume forecast, such as " +"holidays, sales, or tasks." +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_end +msgid "Forecast Date End" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_start +msgid "Forecast Date Start" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_granularity +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_granularity +msgid "Forecast Line Granularity" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_horizon +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_horizon +msgid "Forecast Line Horizon" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_project_project_stage__forecast_line_type +msgid "Forecast Line Type" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__group_forecast_line_on_quotation +msgid "Forecast Line on Quotations" +msgstr "" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Forecast Management" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_product_product__forecast_role_id +#: model:ir.model.fields,field_description:project_forecast_line.field_product_template__forecast_role_id +#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_role_id +msgid "Forecast Role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_roles +#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_role +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_employee_form +msgid "Forecast Roles" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.server,name:project_forecast_line.cron_forecast_lines_ir_actions_server +#: model:ir.cron,cron_name:project_forecast_line.cron_forecast_lines +#: model:ir.cron,name:project_forecast_line.cron_forecast_lines +msgid "Forecast recomputation" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_role_id +msgid "Forecast role" +msgstr "" + +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Group By" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__id +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__id +msgid "ID" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_job +msgid "Job Position" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line____last_update +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role____last_update +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role____last_update +msgid "Last Modified on" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_uid +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_date +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_date +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_date +msgid "Last Updated on" +msgstr "" + +#. module: project_forecast_line +#: code:addons/project_forecast_line/models/hr_leave.py:0 +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__hr_leave_id +#, python-format +msgid "Leave" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__main_role_id +msgid "Main Role" +msgstr "" + +#. module: project_forecast_line +#: model:res.groups,name:project_forecast_line.group_forecast_line_on_quotation +msgid "Manage Forecast Dates on Quotations" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_model +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Model" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__month +msgid "Month" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__name +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__name +msgid "Name" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_horizon +#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_horizon +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Number of month for the forecast planning" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_granularity +#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_granularity +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Periodicity of the forecast that will be generated" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_end +msgid "Planned end date" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_start +msgid "Planned start date" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_product_template +msgid "Product Template" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_project_project +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__project_id +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Project" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_project_project_stage +msgid "Project Stage" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__rate +msgid "Rate" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_id +msgid "Record ID" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_resource_calendar_leaves +msgid "Resource Time Off Detail" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__role_ids +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__role_id +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_job__role_id +#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search +msgid "Role" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_id +msgid "Sale" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_line_id +msgid "Sale line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence +msgid "Sequence" +msgstr "" + +#. module: project_forecast_line +#: model:ir.actions.act_window,name:project_forecast_line.forecast_config_settings_action +#: model:ir.ui.menu,name:project_forecast_line.forecast_config_settings_menu_action +msgid "Settings" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_project_task +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__task_id +msgid "Task" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model,name:project_forecast_line.model_hr_leave +msgid "Time Off" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__type +msgid "Type" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__week +msgid "Week" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id +msgid "" +"technical field giving the name of the resource " +"(model=hr.employee.forecast.role) line for that employee and that period" +msgstr "" + +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_project_project_stage__forecast_line_type +msgid "type of forecast lines created by the tasks of projects in that stage" +msgstr "" diff --git a/project_forecast_line/models/__init__.py b/project_forecast_line/models/__init__.py new file mode 100644 index 0000000000..9131303b85 --- /dev/null +++ b/project_forecast_line/models/__init__.py @@ -0,0 +1,14 @@ +from . import forecast_line +from . import forecast_role +from . import hr_employee +from . import product_template +from . import sale_order +from . import sale_order_line +from . import res_company +from . import hr_leave +from . import project_task +from . import account_analytic_line +from . import res_config_settings +from . import resource_calendar_leaves +from . import project_project_stage +from . import project_project diff --git a/project_forecast_line/models/account_analytic_line.py b/project_forecast_line/models/account_analytic_line.py new file mode 100644 index 0000000000..d90a5be38f --- /dev/null +++ b/project_forecast_line/models/account_analytic_line.py @@ -0,0 +1,18 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, models + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + @api.model_create_multi + def create(self, vals_list): + recs = super().create(vals_list) + recs.mapped("task_id")._update_forecast_lines() + return recs + + def write(self, values): + res = super().write(values) + self.mapped("task_id")._update_forecast_lines() + return res diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py new file mode 100644 index 0000000000..50d54f2da8 --- /dev/null +++ b/project_forecast_line/models/forecast_line.py @@ -0,0 +1,322 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging +from datetime import datetime, time + +import pytz +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.tools import date_utils, mute_logger + +_logger = logging.getLogger(__name__) + + +class ForecastLine(models.Model): + """ + we generate 1 forecast line per period defined on the current company (day, week, month). + """ + + _name = "forecast.line" + _order = "date_from, employee_id, project_id" + _description = "Forecast" + + name = fields.Char(required=True) + date_from = fields.Date( + required=True, help="Date of the period start for this line" + ) + date_to = fields.Date(required=True) + forecast_role_id = fields.Many2one( + "forecast.role", string="Forecast role", required=True, index=True + ) + employee_id = fields.Many2one("hr.employee", string="Employee") + employee_forecast_role_id = fields.Many2one( + "hr.employee.forecast.role", string="Employee Forecast Role" + ) + project_id = fields.Many2one("project.project", index=True, string="Project") + task_id = fields.Many2one("project.task", index=True, string="Task") + sale_id = fields.Many2one( + "sale.order", + related="sale_line_id.order_id", + store=True, + index=True, + string="Sale", + ) + sale_line_id = fields.Many2one("sale.order.line", index=True, string="Sale line") + hr_leave_id = fields.Many2one("hr.leave", index=True, string="Leave") + forecast_hours = fields.Float( + "Forecast", + help="Forecast (in hours). Forecast is positive for resources which add forecast, " + "such as employees, and negative for things which consume forecast, such as " + "holidays, sales, or tasks.", + ) + cost = fields.Monetary( + help="Cost, in company currency. Cost is positive for things which add forecast, " + "such as employees and negative for things which consume forecast such as " + "holidays, sales, or tasks. ", + ) + consolidated_forecast = fields.Float( + digits=(12, 5), + store=True, + compute="_compute_consolidated_forecast", + ) + + currency_id = fields.Many2one(related="company_id.currency_id", store=True) + company_id = fields.Many2one( + "res.company", required=True, default=lambda s: s.env.company + ) + type = fields.Selection( + [("forecast", "Forecast"), ("confirmed", "Confirmed")], + required=True, + default="forecast", + ) + res_model = fields.Char(string="Model", index=True) + res_id = fields.Integer(string="Record ID", index=True) + employee_resource_forecast_line_id = fields.Many2one( + "forecast.line", + store=True, + index=True, + compute="_compute_employee_forecast_line_id", + ondelete="set null", + help="technical field giving the name of the resource " + "(model=hr.employee.forecast.role) line for that employee and that period", + ) + employee_resource_consumption_ids = fields.One2many( + "forecast.line", "employee_resource_forecast_line_id" + ) + + @api.depends("employee_id", "date_from", "type", "res_model") + def _compute_employee_forecast_line_id(self): + employees = self.mapped("employee_id") + date_froms = self.mapped("date_from") + date_tos = self.mapped("date_to") + if employees: + lines = self.search( + [ + ("employee_id", "in", employees.ids), + ("res_model", "=", "hr.employee.forecast.role"), + ("date_from", ">=", min(date_froms)), + ("date_to", "<=", max(date_tos)), + ("type", "=", "confirmed"), + ] + ) + else: + lines = self.env["forecast.line"] + capacities = {} + for line in lines: + capacities[(line.employee_id.id, line.date_from)] = 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 + ) + else: + rec.employee_resource_forecast_line_id = False + + def _convert_forecast(self, data): + """ + Converts consolidated forecast from hours to days + """ + self.ensure_one() + to_convert_uom = self.env.ref("uom.product_uom_day") + project_time_mode_id = self.company_id.project_time_mode_id + if self.res_model != "hr.employee.forecast.role": + return -project_time_mode_id._compute_quantity( + self.forecast_hours, to_convert_uom, round=False + ) + else: + forecast_hours = self.forecast_hours + data.get(self.id, 0) + return project_time_mode_id._compute_quantity( + forecast_hours, to_convert_uom, round=False + ) + + @api.depends("employee_resource_consumption_ids.forecast_hours", "forecast_hours") + def _compute_consolidated_forecast(self): + data = {} + for d in self.env["forecast.line"].read_group( + [("employee_resource_forecast_line_id", "in", self.ids)], + fields=["forecast_hours"], + groupby=["employee_resource_forecast_line_id"], + ): + data[d["employee_resource_forecast_line_id"][0]] = d["forecast_hours"] + for rec in self: + rec.consolidated_forecast = rec._convert_forecast(data) + + def prepare_forecast_lines( + self, + name, + date_from, + date_to, + ttype, + forecast_hours, + unit_cost, + res_model="", + res_id=0, + **kwargs + ): + common_value_dict = { + "company_id": self.env.company.id, + "name": name, + "type": ttype, + "forecast_role_id": kwargs.get("forecast_role_id", False), + "employee_id": kwargs.get("employee_id", False), + "project_id": kwargs.get("project_id", False), + "task_id": kwargs.get("task_id", False), + "sale_line_id": kwargs.get("sale_line_id", False), + "hr_leave_id": kwargs.get("hr_leave_id", False), + "employee_forecast_role_id": kwargs.get("employee_forecast_role_id", False), + "res_model": res_model, + "res_id": res_id, + } + forecast_line_vals = [] + if common_value_dict["employee_id"]: + resource = ( + self.env["hr.employee"] + .browse(common_value_dict["employee_id"]) + .resource_id + ) + calendar = resource.calendar_id + else: + resource = self.env["resource.resource"] + calendar = self.env.company.resource_calendar_id + for updates in self._split_per_period( + date_from, date_to, forecast_hours, unit_cost, resource, calendar + ): + values = common_value_dict.copy() + values.update(updates) + forecast_line_vals.append(values) + return forecast_line_vals + + def _company_horizon_end(self): + company = self.env.company + today = fields.Date.context_today(self) + horizon_end = today + relativedelta(months=company.forecast_line_horizon) + return horizon_end + + def _split_per_period( + self, date_from, date_to, forecast_hours, unit_cost, resource, calendar + ): + company = self.env.company + today = fields.Date.context_today(self) + granularity = company.forecast_line_granularity + delta = date_utils.get_timedelta(1, granularity) + horizon_end = self._company_horizon_end() + # the date_to passed as argument is "included". We want to be able to + # reason with this date "excluded" when doing substractions to compute + # a number of days -> add 1d + date_to += relativedelta(days=1) + horiz_date_from = max(date_from, today) + horiz_date_to = min(date_to, horizon_end) + curr_date = date_utils.start_of(horiz_date_from, granularity) + if horiz_date_to <= horiz_date_from: + return + whole_period_forecast = self._number_of_hours( + horiz_date_from, horiz_date_to, resource, calendar + ) + if whole_period_forecast == 0: + # the resource if completely off during the period -> we cannot + # plan the forecast in the period. We put the whole forecast on the + # day after the period. + # TODO future improvement: dump this on the + # first day when the employee is not on holiday + _logger.warning( + "resource %s has 0 forecast on period %s -> %s", + resource, + horiz_date_from, + horiz_date_to, + ) + yield { + "date_from": horiz_date_to, + "date_to": horiz_date_to + delta - relativedelta(days=1), + "forecast_hours": forecast_hours, + "cost": forecast_hours * unit_cost, + } + return + daily_forecast = forecast_hours / whole_period_forecast + if daily_forecast == 0: + return + while curr_date < horiz_date_to: + next_date = curr_date + delta + # XXX fix periods which are not entirely in the horizon + # (min max trick on the numerator of the division) + period_forecast = self._number_of_hours( + max(curr_date, date_from), + min(next_date, date_to), + resource, + calendar, + ) + if period_forecast == 0: + # don"t create forecast lines with a forecast of 0 + curr_date = next_date + continue + period_forecast *= daily_forecast + period_cost = period_forecast * unit_cost + updates = { + "date_from": curr_date, + "date_to": next_date - relativedelta(days=1), + "forecast_hours": period_forecast, + "cost": period_cost, + } + yield updates + curr_date = next_date + + @api.model + def _cron_recompute_all(self, force_company_id=None): + today = fields.Date.context_today(self) + ForecastLine = self.env["forecast.line"].sudo() + if force_company_id: + companies = self.env["res.company"].browse(force_company_id) + else: + companies = self.env["res.company"].search([]) + for company in companies: + ForecastLine = ForecastLine.with_company(company) + limit_date = date_utils.start_of(today, company.forecast_line_granularity) + stale_forecast_lines = ForecastLine.search( + [ + ("date_from", "<", limit_date), + ("company_id", "=", company.id), + ] + ) + stale_forecast_lines.unlink() + + # always start with forecast role to ensure we can compute the + # employee_resource_forecast_line_id field + self.env["hr.employee.forecast.role"]._recompute_forecast_lines( + force_company_id=force_company_id + ) + self.env["sale.order.line"]._recompute_forecast_lines( + force_company_id=force_company_id + ) + self.env["hr.leave"]._recompute_forecast_lines( + force_company_id=force_company_id + ) + self.env["project.task"]._recompute_forecast_lines( + force_company_id=force_company_id + ) + # fix weird issue where the employee_resource_forecast_line_id seems to + # not be always computed + ForecastLine.search([])._compute_employee_forecast_line_id() + + @api.model + def convert_days_to_hours(self, days): + uom_day = self.env.ref("uom.product_uom_day") + uom_hour = self.env.ref("uom.product_uom_hour") + return uom_day._compute_quantity(days, uom_hour) + + @api.model + def _number_of_hours(self, date_from, date_to, resource, calendar): + tzinfo = pytz.timezone(calendar.tz) + start_dt = tzinfo.localize(datetime.combine(date_from, time(0))) + end_dt = tzinfo.localize(datetime.combine(date_to, time(0))) + intervals = calendar._work_intervals_batch( + start_dt, end_dt, resources=resource + )[resource.id] + nb_hours = sum( + (stop - start).total_seconds() / 3600 for start, stop, meta in intervals + ) + return nb_hours + + def unlink(self): + # we routinely unlink forecast lines, let"s not fill the logs with this + with mute_logger("odoo.models.unlink"): + return super().unlink() diff --git a/project_forecast_line/models/forecast_role.py b/project_forecast_line/models/forecast_role.py new file mode 100644 index 0000000000..079b35a658 --- /dev/null +++ b/project_forecast_line/models/forecast_role.py @@ -0,0 +1,11 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ForecastRole(models.Model): + _name = "forecast.role" + _description = "Employee role for task matching" + + name = fields.Char(required=True) + description = fields.Text() diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py new file mode 100644 index 0000000000..2c854eff82 --- /dev/null +++ b/project_forecast_line/models/hr_employee.py @@ -0,0 +1,148 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class HrJob(models.Model): + _inherit = "hr.job" + + role_id = fields.Many2one("forecast.role") + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + role_ids = fields.One2many("hr.employee.forecast.role", "employee_id") + main_role_id = fields.Many2one("forecast.role", compute="_compute_main_role_id") + + def _compute_main_role_id(self): + # can"t store as it depends on current date + today = fields.Date.context_today(self) + for rec in self: + rec.main_role_id = rec.role_ids.filtered( + lambda r: r.date_start <= today + and not r.date_end + or r.date_end >= today + )[:1].role_id + + def write(self, values): + values = self._check_job_role(values) + return super().write(values) + + @api.model_create_multi + @api.returns("self", lambda value: value.id) + def create(self, values): + values = [self._check_job_role(val) for val in values] + return super().create(values) + + def _check_job_role(self, values): + """helper method + ensures that you get a role when you set a job with a role""" + new_job_id = values.get("job_id") + if new_job_id: + job = self.env["hr.job"].browse(new_job_id) + if job.role_id and "role_ids" not in values: + values = values.copy() + values["role_ids"] = [ + fields.Command.clear(), + fields.Command.create({"role_id": job.role_id.id}), + ] + return values + + +class HrEmployeeForecastRole(models.Model): + _name = "hr.employee.forecast.role" + _description = "Employee forecast role" + _order = "employee_id, date_start, sequence, rate DESC, id" + + employee_id = fields.Many2one("hr.employee", required=True, ondelete="cascade") + role_id = fields.Many2one("forecast.role", required=True) + date_start = fields.Date(required=True, default=fields.Date.today) + date_end = fields.Date() + rate = fields.Integer(default=100) + sequence = fields.Integer() + company_id = fields.Many2one(related="employee_id.company_id", store=True) + # TODO: + # ensure sum of rate = 100 + + @api.model_create_multi + def create(self, vals_list): + recs = super().create(vals_list) + recs._update_forecast_lines() + return recs + + def write(self, values): + res = super().write(values) + self._update_forecast_lines() + return res + + def _update_forecast_lines(self): + today = fields.Date.context_today(self) + ForecastLine = self.env["forecast.line"].sudo() + if not self: + return ForecastLine + leaves = self.env["hr.leave"].search( + [ + ("employee_id", "in", self.mapped("employee_id").ids), + ("state", "!=", "cancel"), + ("date_to", ">=", min(self.mapped("date_start"))), + ] + ) + leaves._update_forecast_lines() + forecast_vals = [] + ForecastLine.search( + [("res_id", "in", self.ids), ("res_model", "=", self._name)] + ).unlink() + horizon_end = ForecastLine._company_horizon_end() + for rec in self: + if rec.date_end: + date_end = rec.date_end + else: + date_end = horizon_end - relativedelta(days=1) + date_start = max(rec.date_start, today) + resource = rec.employee_id.resource_id + calendar = resource.calendar_id + + forecast = ForecastLine._number_of_hours( + date_start, + date_end + relativedelta(days=1), + resource, + calendar, + ) + forecast_vals += ForecastLine.prepare_forecast_lines( + name="Employee %s as %s (%d%%)" + % (rec.employee_id.name, rec.role_id.name, rec.rate), + date_from=rec.date_start, + date_to=date_end, + forecast_hours=forecast * rec.rate / 100.0, + unit_cost=rec.employee_id.timesheet_cost, # XXX to check + ttype="confirmed", + forecast_role_id=rec.role_id.id, + employee_id=rec.employee_id.id, + employee_forecast_role_id=rec.id, + res_model=self._name, + res_id=rec.id, + ) + + return ForecastLine.create(forecast_vals) + + @api.model + def _recompute_forecast_lines(self, force_company_id=None): + today = fields.Date.context_today(self) + if force_company_id: + companies = self.env["res.company"].browse(force_company_id) + else: + companies = self.env["res.company"].search([]) + for company in companies: + to_update = self.with_company(company).search( + [ + "|", + ("date_end", "=", False), + ("date_end", ">=", today), + ("company_id", "=", company.id), + ] + ) + to_update._update_forecast_lines() diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py new file mode 100644 index 0000000000..7c7c7c18d1 --- /dev/null +++ b/project_forecast_line/models/hr_leave.py @@ -0,0 +1,78 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) + + +class HrLeave(models.Model): + _inherit = "hr.leave" + + @api.model_create_multi + def create(self, vals_list): + leaves = super().create(vals_list) + leaves._update_forecast_lines() + return leaves + + def write(self, values): + res = super().write(values) + self._update_forecast_lines() + return res + + def _update_forecast_lines(self): + forecast_vals = [] + ForecastLine = self.env["forecast.line"].sudo() + # XXX try to be smarter and only unlink those needing unlinking, update the others + ForecastLine.search( + [("res_id", "in", self.ids), ("res_model", "=", self._name)] + ).unlink() + leaves = self.filtered_domain([("state", "!=", "refuse")]) + for leave in leaves: + if not leave.employee_id.main_role_id: + _logger.warning( + "No forecast role for employee %s (%s)", + leave.employee_id.name, + leave.employee_id, + ) + continue + if leave.state == "validate": + # will be handled by the resource.calendar.leaves + continue + else: + forecast_type = "forecast" + forecast_vals += ForecastLine.prepare_forecast_lines( + name=_("Leave"), + date_from=leave.date_from.date(), + date_to=leave.date_to.date(), + ttype=forecast_type, + forecast_hours=ForecastLine.convert_days_to_hours( + -1 * leave.number_of_days + ), + unit_cost=leave.employee_id.timesheet_cost, + forecast_role_id=leave.employee_id.main_role_id.id, + hr_leave_id=leave.id, + res_model=self._name, + res_id=leave.id, + ) + return ForecastLine.create(forecast_vals) + + @api.model + def _recompute_forecast_lines(self, force_company_id=None): + today = fields.Date.context_today(self) + if force_company_id: + companies = self.env["res.company"].browse(force_company_id) + else: + companies = self.env["res.company"].search([]) + for company in companies: + to_update = self.with_company(company).search( + [ + ("date_to", ">=", today), + ("employee_company_id", "=", company.id), + ] + ) + to_update._update_forecast_lines() + + +# XXX: leave request should create forcast negative forecast? diff --git a/project_forecast_line/models/product_template.py b/project_forecast_line/models/product_template.py new file mode 100644 index 0000000000..6fd7faa8d8 --- /dev/null +++ b/project_forecast_line/models/product_template.py @@ -0,0 +1,9 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + forecast_role_id = fields.Many2one("forecast.role") diff --git a/project_forecast_line/models/project_project.py b/project_forecast_line/models/project_project.py new file mode 100644 index 0000000000..0daf904664 --- /dev/null +++ b/project_forecast_line/models/project_project.py @@ -0,0 +1,18 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + def _update_forecast_lines_trigger_fields(self): + return ["stage_id"] + + def write(self, values): + res = super().write(values) + written_fields = list(values.keys()) + trigger_fields = self._update_forecast_lines_trigger_fields() + if any(field in written_fields for field in trigger_fields): + self.task_ids._update_forecast_lines() + return res diff --git a/project_forecast_line/models/project_project_stage.py b/project_forecast_line/models/project_project_stage.py new file mode 100644 index 0000000000..02eb6d63dc --- /dev/null +++ b/project_forecast_line/models/project_project_stage.py @@ -0,0 +1,21 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ProjectProjectStage(models.Model): + _inherit = "project.project.stage" + + forecast_line_type = fields.Selection( + [("forecast", "Forecast"), ("confirmed", "Confirmed")], + help="type of forecast lines created by the tasks of projects in that stage", + ) + + def write(self, values): + res = super().write(values) + if "forecast_line_type" in values: + projects = self.env["project.project"].search( + [("stage_id", "in", self.ids)] + ) + projects.mapped("task_ids")._update_forecast_lines() + return res diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py new file mode 100644 index 0000000000..7e4df82a15 --- /dev/null +++ b/project_forecast_line/models/project_task.py @@ -0,0 +1,155 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ProjectTask(models.Model): + _inherit = "project.task" + + forecast_role_id = fields.Many2one("forecast.role") + forecast_date_planned_start = fields.Date("Planned start date") + forecast_date_planned_end = fields.Date("Planned end date") + + @api.model_create_multi + def create(self, vals_list): + # compatibility with fields from project_enterprise + for vals in vals_list: + if "planned_date_begin" in vals: + vals["forecast_date_planned_start"] = vals["planned_date_begin"] + if "planned_date_end" in vals: + vals["forecast_date_planned_end"] = vals["planned_date_end"] + tasks = super().create(vals_list) + tasks._update_forecast_lines() + return tasks + + def _update_forecast_lines_trigger_fields(self): + return [ + "sale_order_line_id", + "forecast_role_id", + "forecast_date_planned_start", + "forecast_date_planned_end", + "remaining_hours", + "name", + "planned_time", + "user_ids", + ] + + def write(self, values): + # compatibility with fields from project_enterprise + if "planned_date_begin" in values: + values["forecast_date_planned_start"] = values["planned_date_begin"] + if "planned_date_end" in values: + values["forecast_date_planned_end"] = values["planned_date_end"] + res = super().write(values) + written_fields = list(values.keys()) + trigger_fields = self._update_forecast_lines_trigger_fields() + if any(field in written_fields for field in trigger_fields): + self._update_forecast_lines() + return res + + @api.onchange("user_ids") + def onchange_user_ids(self): + for task in self: + if not task.user_ids: + continue + if task.forecast_role_id: + continue + employees = task.mapped("user_ids.employee_id") + for employee in employees: + if employee.main_role_id: + task.forecast_role_id = employee.main_role_id + break + + def _update_forecast_lines(self): + today = fields.Date.context_today(self) + forecast_vals = [] + ForecastLine = self.env["forecast.line"].sudo() + # XXX try to be smarter and only unlink those needing unlinking, update the others + ForecastLine.search( + [("res_id", "in", self.ids), ("res_model", "=", self._name)] + ).unlink() + for task in self: + if not task.forecast_role_id: + _logger.info("skip task %s: no forecast role", task) + continue + elif task.project_id.stage_id: + forecast_type = task.project_id.stage_id.forecast_line_type + if not forecast_type: + _logger.info("skip task %s: no forecast for project state", task) + continue # closed / cancelled stage + elif task.sale_line_id: + sale_state = task.sale_line_id.state + if sale_state == "cancel": + _logger.info("skip task %s: cancelled sale", task) + elif sale_state == "sale": + forecast_type = "confirmed" + else: + # no forecast line for cancelled sales + # + # TODO have forecast quantity if the sale is in Draft and we + # 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 + ): + _logger.info("skip task %s: no planned dates", task) + continue + if not task.remaining_hours: + _logger.info("skip task %s: no remaining hours", task) + continue + if task.remaining_hours < 0: + _logger.info("skip task %s: negative remaining hours", task) + continue + date_start = max(today, task.forecast_date_planned_start) + date_end = max(today, task.forecast_date_planned_end) + employee_ids = task.mapped("user_ids.employee_id").ids + if not employee_ids: + employee_ids = [False] + _logger.debug( + "compute forecast for task %s: %s to %s %sh", + task, + date_start, + date_end, + task.remaining_hours, + ) + forecast_hours = task.remaining_hours / len(employee_ids) + for employee_id in employee_ids: + forecast_vals += ForecastLine.prepare_forecast_lines( + name=task.name, + date_from=date_start, + date_to=date_end, + ttype=forecast_type, + forecast_hours=-1 * forecast_hours, + # XXX currency + unit conversion + unit_cost=task.sale_line_id.product_id.standard_price, + forecast_role_id=task.forecast_role_id.id, + sale_line_id=task.sale_line_id.id, + task_id=task.id, + project_id=task.project_id.id, + employee_id=employee_id, + res_model=self._name, + res_id=task.id, + ) + return ForecastLine.create(forecast_vals) + + @api.model + def _recompute_forecast_lines(self, force_company_id=None): + today = fields.Date.context_today(self) + if force_company_id: + companies = self.env["res.company"].browse(force_company_id) + else: + companies = self.env["res.company"].search([]) + for company in companies: + to_update = self.with_company(company).search( + [ + ("forecast_date_planned_end", ">=", today), + ("company_id", "=", company.id), + ] + ) + to_update._update_forecast_lines() diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py new file mode 100644 index 0000000000..4d88fee87d --- /dev/null +++ b/project_forecast_line/models/res_company.py @@ -0,0 +1,25 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + forecast_line_granularity = fields.Selection( + [("day", "Day"), ("week", "Week"), ("month", "Month")], + default="month", + help="Periodicity of the forecast that will be generated", + ) + forecast_line_horizon = fields.Integer( + help="Number of month for the forecast planning", default=12 + ) + + def write(self, values): + res = super().write(values) + if "forecast_line_granularity" in values or "forecast_line_horizon" in values: + for company in self: + self.env["forecast.line"]._cron_recompute_all( + force_company_id=company.id + ) + return res diff --git a/project_forecast_line/models/res_config_settings.py b/project_forecast_line/models/res_config_settings.py new file mode 100644 index 0000000000..4ac43649ec --- /dev/null +++ b/project_forecast_line/models/res_config_settings.py @@ -0,0 +1,19 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + forecast_line_granularity = fields.Selection( + related="company_id.forecast_line_granularity", readonly=False + ) + forecast_line_horizon = fields.Integer( + related="company_id.forecast_line_horizon", 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/models/resource_calendar_leaves.py b/project_forecast_line/models/resource_calendar_leaves.py new file mode 100644 index 0000000000..5dfaed8d72 --- /dev/null +++ b/project_forecast_line/models/resource_calendar_leaves.py @@ -0,0 +1,32 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ResourceCalendarLeaves(models.Model): + _inherit = "resource.calendar.leaves" + + @api.model_create_multi + def create(self, vals_list): + recs = super().create(vals_list) + recs._update_forecast_lines() + return recs + + def write(self, values): + res = super().write(values) + self._update_forecast_lines() + return res + + def _update_forecast_lines(self): + resources = self.mapped("resource_id") + if resources: + employees = self.env["hr.employee"].search([("id", "in", resources.ids)]) + else: + employees = self.env["hr.employee"].search( + [("company_id", "in", self.mapped("company_id").ids)] + ) + roles = self.env["hr.employee.forecast.role"].search( + [("employee_id", "in", employees.ids)] + ) + roles._update_forecast_lines() diff --git a/project_forecast_line/models/sale_order.py b/project_forecast_line/models/sale_order.py new file mode 100644 index 0000000000..1aa06a492b --- /dev/null +++ b/project_forecast_line/models/sale_order.py @@ -0,0 +1,32 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + default_forecast_date_start = fields.Date() + default_forecast_date_end = fields.Date() + + def action_cancel(self): + res = super().action_cancel() + self.filtered(lambda r: r.state == "cancel").mapped( + "order_line" + )._update_forecast_lines() + return res + + def action_confirm(self): + res = super().action_confirm() + self.filtered(lambda r: r.state == "sale").mapped( + "order_line" + )._update_forecast_lines() + return res + + def write(self, values): + res = super().write(values) + if self and "project_id" in values: + self.env["forecast.line"].sudo().search( + [("sale_id", "in", self.ids)] + ).write({"project_id": values["project_id"]}) + return res diff --git a/project_forecast_line/models/sale_order_line.py b/project_forecast_line/models/sale_order_line.py new file mode 100644 index 0000000000..cd8d680aa0 --- /dev/null +++ b/project_forecast_line/models/sale_order_line.py @@ -0,0 +1,137 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + forecast_date_start = fields.Date() + forecast_date_end = fields.Date() + + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + lines._update_forecast_lines() + return lines + + def _update_forecast_lines(self): + forecast_vals = [] + ForecastLine = self.env["forecast.line"].sudo() + # XXX try to be smarter and only unlink those needing unlinking, update the others + ForecastLine.search( + [("res_id", "in", self.ids), ("res_model", "=", self._name)] + ).unlink() + for line in self: + if not line.product_id.forecast_role_id: + continue + elif line.state in ("cancel", "sale"): + # no forecast line for confirmed sales -> this is handled by projects and tasks + continue + elif not (line.forecast_date_end and line.forecast_date_start): + _logger.info( + "sale line with forecast product but no dates -> ignoring %s", + line.id, + ) + continue + else: + forecast_type = "forecast" + uom = line.product_uom + quantity_hours = uom._compute_quantity( + line.product_uom_qty, self.env.ref("uom.product_uom_hour") + ) + forecast_vals += ForecastLine.prepare_forecast_lines( + name=line.name, + date_from=line.forecast_date_start, + date_to=line.forecast_date_end, + ttype=forecast_type, + forecast_hours=-1 * quantity_hours, + unit_cost=line.product_id.standard_price, # XXX currency + unit conversion + forecast_role_id=line.product_id.forecast_role_id.id, + sale_line_id=line.id, + project_id=line.project_id.id, + res_model="sale.order.line", + res_id=line.id, + ) + return ForecastLine.create(forecast_vals) + + @api.model + def _recompute_forecast_lines(self, force_company_id=None): + today = fields.Date.context_today(self) + if force_company_id: + companies = self.env["res.company"].browse(force_company_id) + else: + companies = self.env["res.company"].search([]) + for company in companies: + to_update = self.with_company(company).search( + [ + ("forecast_date_end", ">=", today), + ("company_id", "=", company.id), + ] + ) + to_update._update_forecast_lines() + + def _update_forecast_lines_trigger_fields(self): + return [ + "state", + "product_uom_qty", + "forecast_date_start", + "forecast_date_end", + "product_id", + "name", + ] + + def write(self, values): + res = super().write(values) + written_fields = list(values.keys()) + trigger_fields = self._update_forecast_lines_trigger_fields() + if any(field in written_fields for field in trigger_fields): + self._update_forecast_lines() + return res + + @api.onchange("product_id") + def product_id_change(self): + res = super().product_id_change() + for line in self: + if not line.product_id.forecast_role_id: + line.forecast_date_start = False + line.forecast_date_end = False + else: + if ( + not line.forecast_date_start + and line.order_id.default_forecast_date_start + ): + line.forecast_date_start = line.order_id.default_forecast_date_start + if ( + not line.forecast_date_end + and line.order_id.default_forecast_date_end + ): + line.forecast_date_end = line.order_id.default_forecast_date_end + return res + + def _timesheet_create_task_prepare_values(self, project): + values = super()._timesheet_create_task_prepare_values(project) + values.update( + { + "forecast_role_id": self.product_id.forecast_role_id.id, + "forecast_date_planned_end": self.forecast_date_end, + "forecast_date_planned_start": self.forecast_date_start, + } + ) + return values + + def _timesheet_create_project(self): + project = super()._timesheet_create_project() + if self.product_id.project_template_id and self.product_id.forecast_role_id: + project.tasks.write( + { + "forecast_role_id": self.product_id.forecast_role_id.id, + "date_end": self.forecast_date_end, + "date_planned_start": self.forecast_date_start, + } + ) + return project diff --git a/project_forecast_line/readme/CONTRIBUTORS.rst b/project_forecast_line/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..ed6bc85941 --- /dev/null +++ b/project_forecast_line/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Alexandre Fayolle <alexandre.fayolle@camptocamp.com> +* Maksym Yankin <maksym.yankin@camptocamp.com> diff --git a/project_forecast_line/readme/DESCRIPTION.rst b/project_forecast_line/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..b2e45eba26 --- /dev/null +++ b/project_forecast_line/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module allows to plan your resources using forecast lines. +For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees. +Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed". diff --git a/project_forecast_line/security/forecast_line_security.xml b/project_forecast_line/security/forecast_line_security.xml new file mode 100644 index 0000000000..bd57ee533d --- /dev/null +++ b/project_forecast_line/security/forecast_line_security.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo noupdate="1"> + <record id="group_forecast_line_on_quotation" model="res.groups"> + <field name="name">Manage Forecast Dates on Quotations</field> + <field name="category_id" ref="base.module_category_hidden" /> + </record> + + <record model="ir.rule" id="forecast_line_comp_rule"> + <field name="name">Forecast multi-company</field> + <field name="model_id" ref="model_forecast_line" /> + <field name="domain_force">[('company_id', 'in', company_ids)]</field> + </record> +</odoo> diff --git a/project_forecast_line/security/ir.model.access.csv b/project_forecast_line/security/ir.model.access.csv new file mode 100644 index 0000000000..9001101172 --- /dev/null +++ b/project_forecast_line/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +project_forecast_line.access_forecast_line,access_forecast_line,project_forecast_line.model_forecast_line,base.group_user,1,0,0,0 +project_forecast_line.access_forecast_role,access_forecast_role,project_forecast_line.model_forecast_role,base.group_user,1,0,0,0 +project_forecast_line.access_forecast_role_hr_manager,access_forecast_role_hr_manager,project_forecast_line.model_forecast_role,hr.group_hr_user,1,1,1,0 +project_forecast_line.access_hr_employee_forecast_role,access_hr_employee_forecast_role,project_forecast_line.model_hr_employee_forecast_role,base.group_user,1,0,0,0 +project_forecast_line.access_hr_manager_forecast_role,access_hr_manager_forecast_role,project_forecast_line.model_hr_employee_forecast_role,hr.group_hr_user,1,1,1,0 diff --git a/project_forecast_line/static/description/icon.png b/project_forecast_line/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ebd3d6aadab4c053b3b78109d9c6eb25027d9a67 GIT binary patch literal 4738 zcma)Ai9b}|8=fRIh-|~yB4X^zWJ$sxvXylhStd)!l6`9;8kD`hC~L@?v4+Sl8GClg zlI;5)CZ^x%Klq*b+<QKsd(WAB&hx(S^FHr+anC@D9twv-AQ1XH+8RcnPyTn&P=I@t z-BLB^s2=EQX+Zw|`@L%{cnyIt$=}gXGYLrF%m}{6JkAq3;HZXz1d39st&dQIy?kl6 zYv@~SJF4@D`YVa<rD;@56s9AaNrQ(?tK?W1N_C+)hv0#+w9FnJLsJ)+S`skTNJ`cO zCaIgRHqPvV>!v8>9?Cy_Ew*t4i>PY$Zd?AYyxlSL^p9Ihy8~si1SGy_2dc<WmazZ- zcN6CI5U%r`p25MvkWd1FkdeU+Y3c9p9~)zXTw#HFa#2GR6clXP^G!tlp6=h3Whr7n zutayrI`%?*kPrwLH}^RR#WPg_rq*Rm#xD`{oaV<&)8Ei6a&mGaN!^=2YL%3f()d2} zK0Tg`S0PKO-+mW=la`Z{bA4?MgTcIvj_ymyF#8~mKuD|K7HG8LTa7=pkVt6`*bUp{ z>gnl8(h>Yo=QGs9DtG<5FX=p_aC9x4nxQf^7ekN`6}7ugs~l9-mY<*ha3FIP7jUp* z*v@d->(`eTA>@<D#^Z<s41OBS`dD3!Bdr#QIZS^0_IPVbF*)o=W7}k6YD&*=)FAU_ z(LLb_V(t6)?+Jw1SR%^-3g6e)S6yArauEsdYr_3WShlpX!kz3nEMQ}Yez3p~wY+|B zeAkNS$?n5Qq&%v|e3Ztnm_jWv7~9ixkdK&6^4Xuuj<`R+H?u;>99Y$Wp!l{FNgEXD zDne4Stf)xs)pa$-$V=QPrkCd$HRgI!B*8Y-7!y{Nm6g4{y*nOAznW_POa4R6z;oqd zN9c^yZJi$82&|!@;re))P-mIGR_@?`$;tc|JLvjFO+Jk7kWWufhlYl}#`DIPLtqm3 z-}#=`x>U<rQ&WQ?wR_&YcI~!E(yJ=nLMLZu>HWv4d|YM|lam-@<J{rZn0z`K8tuDx z8K6*<b;N6-yTv9V3s~n?Q+xvXISTva$rC+2I>p53WD<$AzdUf2K^JBq;`-=O_$~|% zulHRa<5Q0)H;MW6>%l%l0Y{rM-p$PoiVvI2di{DByw1tVA$Bmk?i}yhMbm-z6;JtM znxmqk_V)HxR-Ed|lRtjk&A4&l`zLV`k+AKEN@$7;HaJ-6@NBrf{i>j#;LV%qYCFZn z#Umplkqo>y6cpHFAFq!Qii<_4>dMbOWbyOy5oURr`883RE=|dQD_=j2y~N$9$Rzmp z20~n1@UOSMgM)s$!eDD_cEJIq8gbF9zOI~lYkS*cRffGFH+O!!`R`U&R#};cyL+hy z|79rN=PKI7qy$sFR*>~{twFTr<3|Xjt*tGn{(WWTUK^g)NA>~3rJJ;fHQu=SXqe<J z9O+=atl2<*1E9mq%sf9opLGjYd?Z?TpS&~I?p`+Jgv`SI=9q3U@0etDLBjE<>_I2G zkj3rokb{-Mg#~*rE6JObYU#?sep^#@HX&!eci%eN+xz^$DEe=uJ46oCRjfV4-UY^( z+gt2Gm)>`_Q~!K=H}hs<m;HFDRjz^G;o4|f6KRc5JF`Z@>gx7%y%t(sBFc?f(%uQ- z=H?E{isx4Py+5FmqLb%y=8Q}Y3kxF>wR6eZGP1IVR0G}J-D0=W9ix&&MqH||U%wti zPESvl^O$*ZyfcTk4#c~+0Q~)DasJEw8JnA%2`MAv<IBs-{^Y9C(*9zMbcT5@ieUEP zp6~&AY&c(E$$P0cM>Cp|BFMq4$i%|JLN{4#wFr+=Gc+7*3_c=e#d9fAl+C7_XQ0$W zczAf~>gs}zwhs69aySV&Is7s*m7hMbJEGbdGYnEPGYuHO<Z8u>h>CJ(NSz-WO&{FX z@|%!%pIZ6#3nf1SAeWVq33BmnGd49fwYGk`IeA@_!$6j@D)Xwt;i+^cc6`;RGoE*# zug}fh{kD;mjg8Gm<_@QdWK7}ks*8(@c8Efpq<Q%jY3W4wNCwp-j-R_bJLQfcEjlb~ zJ~zOk`x`G%QRNpDF!0_=S4+3+jB{{s;P|c1K0i0t`jf}qQEWDSaMedAuaZFM>+d%Z zN#arRi+QU8Y55Of%#I8U3S%aRcCfx8kGLGZk+vgVQmR|d;Fy`2(bUvzYHG@d^-fP` zfFTZGh^wpXKhzh9iP`FrsXWXtH2wYk(P;EAtsRe8e!>-+JR`-YrvZ-Ke$a--MzEmX z3I5vCbGirtnw-W)Wl_;bvrRakENiI@A4y3`N8dzma;PQ?6Xj(tt|zC*p7o*vCL&3C z*DNdTFX~0ct644NsMwNA&qB|gH!Cr3eb;VnXqcFseA{4vikce6!g8=ZJG){ESdi(( zLRm9eCiPV=IaHDL0XrKo8Z)!{I=djGrRw2;jEqdv$x>P=FV%$$tSl@oCI3CEAX<gA zxIzJar6(lpOg<_btbchWido2DVPPR*-Qs|Tj!sNW?71_^*mB{ciY*mhA+CA9Ux^kb z@m@*&&qCL05bc2JjNf0Rp%DfHFW*C>g%Y*t2CC=lRa$D=T3T90KcI)*I9!mafMAq^ zf4B4woc#F%V`HnYtK-nOK%-&I%)`E8-}?IKmr$aS;&}c<r@1yfaLw$`?@3F2X_hsv z+1Hhod{*w&-QQYWbw)biaE%)LeBnozLP7=c+1c5io}N-6!=*@h`Q^n$Ux}`~j&IrO z4BR)6wk?oc#GUBa*x38cA%0;2i1Ml`?<Y?hH2Aa4>8Hh$l9GaSy_|P{bwq>6S{*Ln zoPH|K^un<}{p5#7Gl(x~A`z-)K`UgC&JKf74_mG4;BaSg+)DlFigrQgs2F&}QhKlv z!EjDaV<RJ5QuG)D9G{t)`N50;mv`qPe4q(a@ZY-c=jUf<m%Hk4Wp{Tsv1c)yie4tQ za@<@)s%30!>{_Ds3MqJRp{vrNN6u~X`8QsOYW6)bn<lO$RSp;|<>0_aK-M_9!vwz& zA&p5%h+OJVlV_OQiz+oiqb02yKGoMxI_aR%<GsDTt>M&FhICcfSEx05yJ@eZt?8rL z5EWTjV}co{ehl`_%&P$H|8y3I@^rE8uYhp)QR6oMQF94eBp<^uG&x!Cz1&||SoovX zqy7ES{=TS~*y&1c{2SJ?ni{{s98IY+-BL^Y!UXrT&z|#N8v^&CP^hg$MMcGn*jQj! zvBPB9m*SxU3-92)gP8Ohq6S|_gtU5UYO1}=TMhoe(_?8pQiWY-<}KU}1fs~M`RNEk z<#eaLBN}F0P*heH|Kdem$ls>HCv}e!Yfu=ngmr_$qp_08N|`s8f|pBSL`@`ngrJa6 z$WANuNSAiBlt=8(Ld_s(@T8O@5jZy!6Vq6!)sJuAAk2#V{J(l6o9)Y^u1QOKE_Nr4 zIK$!aWO2)}x>h{J5Pz`NX!*I=W^^s*S2{omfG5Cw^6wuW*tSb><Vwr4^ML7Ms58b( zESO<1t&IHaZ0zb#o|HJ^;lpKsGuj51WP$BvC~0#C$a(!GpW$g?-S(C4Le7UPQwSC3 zApzop0}T`kWn{$Un+i0~Vo%C!y>E{bQcJ6oU|H+c!T%qMJ!FAr65ws~^yI>|C6Iu+ zGWLANu?HHit|f-iB1s~0a=*uILaxJO)!AZ2pYNhgO<6fOF4jek&CJ|FAXble=UZD_ zkvdQ{SJw^F;d;?<p-C~Ts9DRYinouCp02KXrRrPL7~zfjxu~$y(<j99%LV#re&o}W zi3xMLxNDRUG0U3X70YFSWv9HNqGDb_LE3}FM)jXJvW+Hu+Y?s<f`Yats~>I8Gy)pb zrueO)r`HQmEd1*SM3<;pNhrsAUoWq2B9UFrl|B5`^T^0y?z4xg@zBd8W*W-D!9k#J zZ2fM)&o2#RWu>O-xET`6Iy*Y78iPiikkr(X0EwY$b{-yyBpsm8^w&kGe9Fqb+uy8m z9RK-~lAPRaFJsdAR-K8PTl{rbVeng^c|iz)yebWk{YcXs<@Gg&!>EX*y*wd-k*Lc) z+$X;=CFS_QDf^yql1@)|_w87?!vkSQ4Nc9wygZI-5W{FgLo6~iJX{@vfyZ=!h%d8l zOl_b(K0Xcz2vCK+y-2cQNNV1l?+n@PfL+^yAv2C<f+c}0u3sIQnQ3zDgTL9wV%L5h ztPV3XG1bqse=m*tTaW<0nxo?Hf3&gTT6f=Fu-yc(*Y_rzQgugMT-=v0U$&e6^gAIF zwKcH2ee$y-`_vG2MK1vY#qeSXRR1UvYi@5Z5QyS_S;=QNR$>86aa+99>ho_9sJC+u z?L7fIM35m6K0dw@KXJ0DHlv1{+a{2r9Mo_bd*WPsWL0Hli8pSk_pPk|#)MiU;$3d; zRRrRW$6x*01h&tmv*}1@BhYFGtY9#hii!%ALxcu|yw2cg@;WSVe+eHE!7sups-dB= zG1Hh91UJeZ6c7|_ZKwye0zfX24G`cb_vsriG5u@v_aKk}(m~M+@~5_uB4d@41!k9M z3_(s&v96(E$_Y92E9?TToa=-tF2%80U!sse(Dc<|5_%)!pkr;#bLgy!A^6RIjV-vF zB?s3RVCu_KnO)&}`1r9@u(-Ec)}EG3;NC(*!_BquhH8wPj!t)xaY4d@Fbi~bi}1X2 zPm9{(`JLC-7MGWec^UJESHY&@c$8lXX#L*G!<q0wY^OeXo}8SFR3%ZWx&El3?+RSo zsCFWiq9CeHPAkvOQy0o(FH<HaCSpmt*COdTBV4>J<8KidAySULZ$ON!@dbok0a`0M zI$FPrrqgr5cuD|g1^&19jWnnNa%POR7{36T)xT+(kr#|8V}wA$!oz84X%TU%p1!`~ zVq!jCUNVBPWDK&QE#x8;dLv-Rw$^vBs|%r`lJ)%y4*dj&`~D{27b}_JnX2t4o$0~B zig)iIqjoW|7PQuL%{Or*kowo?4`}giK!U}wOH-IixK8N?;vtZ4Q&SBtqauw*9*67W z9)R7Q#w9DHcfW{I!ebr#Qm+RoNw~VX`EO1-kYv1-?^{_VdeCpLLobF+R60b@K!v$F zRmjKl0MN?Bt9!xDHt0_e2Y4DeE^>8NRaIT(=Qr<8L`S_cE-=`fZEju{Vvoqn&+pGv zPHWWDieZ1|Cm1|y8gNGPeXEy$lnvxKSe}GUQ|}B{I*>GzFN8h>j7FUV?)+3kp~B{@ z?<DD%f~pbJjq?Gjs;cJy+QdYKUF;UTKvP?r2*iAx2*$>waL~zKhT|}_!0baK$UhlM z{vfRY0lhMqdncnkl0m)^E#<YKZ8Dtj&O_v5MFj%nj?&U5uSFdPhec4PYB&0RsIETj zP7<~MFbcFIZ~!b83yLGhhGwBFSAYk<t+Xx|#ToLwDErque0+Q!JzDQelNS{ck+7<N zZz57u=Hle^Dd3+BfOXN>kETmuDF~Mzb9IJ?g}G0C!VbZXC2?r15Gp$XnFs`TX6B`k z=ntj;8YpbnNk8f6>G2k;NS6^|Yd%q_H1loJ&yTgW3>Pm-lYVMUC@rw^-v$}@mcnen zJbk0F(b?%KFpf<_05&MD0mYUb@c03{9GLt@-}CkyYwC-B>VnwVK4RikA-y-D#bsTh zNO^c{esS^I*7WCpM0_nX0MFI}xWA`|cJvEh*|#Z$8#gX-DKO4`@tEaas-YpCooixC zeGN+6_69n<#1nBOv)IXb&@q6d?_(1}3C<mS@87>~t{fDBCQ5*PY;A3oIJT<LT701+ zLu_cjx4Uj@EwEOGMMhHh>P95C%xVwX8H9wE;fcEk2hRgdl~4hAVGb!<$h7?9)JtWI z9OtPoAw4;zWD^iKL&&GViuW|vpa1H+2DprP7akEPd9OAgF!1fk#JoXyON(l>{nVot zsotKR=&kjF%WbvV`fb0+kiTNc3v}&3F<abVq~WpVQ<tJ+j_w#K`1F?Anva>1;#qBN z?e*jH4JaxaN{R||JsexU;#`{SLAl(&rG0Giyf4OPoF(k~Gyq@zQ(<d<KHTrKB-1e` zg2il|VW*mr3LIwe^78gH)kRS>vB6ur|KVh02i#$PUi~bNBie2hUnO=%Is{)e+hW*} uAIkjsQl(|Q&Ap<k3Oo~Ql!7b%J@;($>!i_cbR;-bg51$G&?rUOhW!sxvnihd literal 0 HcmV?d00001 diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html new file mode 100644 index 0000000000..a57a3348ed --- /dev/null +++ b/project_forecast_line/static/description/index.html @@ -0,0 +1,423 @@ +<?xml version="1.0" encoding="utf-8" ?> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="https://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<head> +<meta https-equiv="Content-Type" content="text/html; charset=utf-8" /> +<meta name="generator" content="Docutils 0.15.1: https://docutils.sourceforge.net/" /> +<title>Project Forecast Lines</title> +<style type="text/css"> + +/* +:Author: David Goodger (goodger@python.org) +:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $ +:Copyright: This stylesheet has been placed in the public domain. + +Default cascading style sheet for the HTML output of Docutils. + +See https://docutils.sf.net/docs/howto/html-stylesheets.html for how to +customize this style sheet. +*/ + +/* used to remove borders from tables and images */ +.borderless, table.borderless td, table.borderless th { + border: 0 } + +table.borderless td, table.borderless th { + /* Override padding for "table.docutils td" with "! important". + The right padding separates the table cells. */ + padding: 0 0.5em 0 0 ! important } + +.first { + /* Override more specific margin styles with "! important". */ + margin-top: 0 ! important } + +.last, .with-subtitle { + margin-bottom: 0 ! important } + +.hidden { + display: none } + +.subscript { + vertical-align: sub; + font-size: smaller } + +.superscript { + vertical-align: super; + font-size: smaller } + +a.toc-backref { + text-decoration: none ; + color: black } + +blockquote.epigraph { + margin: 2em 5em ; } + +dl.docutils dd { + margin-bottom: 0.5em } + +object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] { + overflow: hidden; +} + +/* Uncomment (and remove this text!) to get bold-faced definition list terms +dl.docutils dt { + font-weight: bold } +*/ + +div.abstract { + margin: 2em 5em } + +div.abstract p.topic-title { + font-weight: bold ; + text-align: center } + +div.admonition, div.attention, div.caution, div.danger, div.error, +div.hint, div.important, div.note, div.tip, div.warning { + margin: 2em ; + border: medium outset ; + padding: 1em } + +div.admonition p.admonition-title, div.hint p.admonition-title, +div.important p.admonition-title, div.note p.admonition-title, +div.tip p.admonition-title { + font-weight: bold ; + font-family: sans-serif } + +div.attention p.admonition-title, div.caution p.admonition-title, +div.danger p.admonition-title, div.error p.admonition-title, +div.warning p.admonition-title, .code .error { + color: red ; + font-weight: bold ; + font-family: sans-serif } + +/* Uncomment (and remove this text!) to get reduced vertical space in + compound paragraphs. +div.compound .compound-first, div.compound .compound-middle { + margin-bottom: 0.5em } + +div.compound .compound-last, div.compound .compound-middle { + margin-top: 0.5em } +*/ + +div.dedication { + margin: 2em 5em ; + text-align: center ; + font-style: italic } + +div.dedication p.topic-title { + font-weight: bold ; + font-style: normal } + +div.figure { + margin-left: 2em ; + margin-right: 2em } + +div.footer, div.header { + clear: both; + font-size: smaller } + +div.line-block { + display: block ; + margin-top: 1em ; + margin-bottom: 1em } + +div.line-block div.line-block { + margin-top: 0 ; + margin-bottom: 0 ; + margin-left: 1.5em } + +div.sidebar { + margin: 0 0 0.5em 1em ; + border: medium outset ; + padding: 1em ; + background-color: #ffffee ; + width: 40% ; + float: right ; + clear: right } + +div.sidebar p.rubric { + font-family: sans-serif ; + font-size: medium } + +div.system-messages { + margin: 5em } + +div.system-messages h1 { + color: red } + +div.system-message { + border: medium outset ; + padding: 1em } + +div.system-message p.system-message-title { + color: red ; + font-weight: bold } + +div.topic { + margin: 2em } + +h1.section-subtitle, h2.section-subtitle, h3.section-subtitle, +h4.section-subtitle, h5.section-subtitle, h6.section-subtitle { + margin-top: 0.4em } + +h1.title { + text-align: center } + +h2.subtitle { + text-align: center } + +hr.docutils { + width: 75% } + +img.align-left, .figure.align-left, object.align-left, table.align-left { + clear: left ; + float: left ; + margin-right: 1em } + +img.align-right, .figure.align-right, object.align-right, table.align-right { + clear: right ; + float: right ; + margin-left: 1em } + +img.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left } + +.align-center { + clear: both ; + text-align: center } + +.align-right { + text-align: right } + +/* reset inner alignment in figures */ +div.align-right { + text-align: inherit } + +/* div.align-center * { */ +/* text-align: left } */ + +.align-top { + vertical-align: top } + +.align-middle { + vertical-align: middle } + +.align-bottom { + vertical-align: bottom } + +ol.simple, ul.simple { + margin-bottom: 1em } + +ol.arabic { + list-style: decimal } + +ol.loweralpha { + list-style: lower-alpha } + +ol.upperalpha { + list-style: upper-alpha } + +ol.lowerroman { + list-style: lower-roman } + +ol.upperroman { + list-style: upper-roman } + +p.attribution { + text-align: right ; + margin-left: 50% } + +p.caption { + font-style: italic } + +p.credits { + font-style: italic ; + font-size: smaller } + +p.label { + white-space: nowrap } + +p.rubric { + font-weight: bold ; + font-size: larger ; + color: maroon ; + text-align: center } + +p.sidebar-title { + font-family: sans-serif ; + font-weight: bold ; + font-size: larger } + +p.sidebar-subtitle { + font-family: sans-serif ; + font-weight: bold } + +p.topic-title { + font-weight: bold } + +pre.address { + margin-bottom: 0 ; + margin-top: 0 ; + font: inherit } + +pre.literal-block, pre.doctest-block, pre.math, pre.code { + margin-left: 2em ; + margin-right: 2em } + +pre.code .ln { color: grey; } /* line numbers */ +pre.code, code { background-color: #eeeeee } +pre.code .comment, code .comment { color: #5C6576 } +pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } +pre.code .literal.string, code .literal.string { color: #0C5404 } +pre.code .name.builtin, code .name.builtin { color: #352B84 } +pre.code .deleted, code .deleted { background-color: #DEB0A1} +pre.code .inserted, code .inserted { background-color: #A3D289} + +span.classifier { + font-family: sans-serif ; + font-style: oblique } + +span.classifier-delimiter { + font-family: sans-serif ; + font-weight: bold } + +span.interpreted { + font-family: sans-serif } + +span.option { + white-space: nowrap } + +span.pre { + white-space: pre } + +span.problematic { + color: red } + +span.section-subtitle { + /* font-size relative to parent (h1..h6 element) */ + font-size: 80% } + +table.citation { + border-left: solid 1px gray; + margin-left: 1px } + +table.docinfo { + margin: 2em 4em } + +table.docutils { + margin-top: 0.5em ; + margin-bottom: 0.5em } + +table.footnote { + border-left: solid 1px black; + margin-left: 1px } + +table.docutils td, table.docutils th, +table.docinfo td, table.docinfo th { + padding-left: 0.5em ; + padding-right: 0.5em ; + vertical-align: top } + +table.docutils th.field-name, table.docinfo th.docinfo-name { + font-weight: bold ; + text-align: left ; + white-space: nowrap ; + padding-left: 0 } + +/* "booktabs" style (no vertical lines) */ +table.docutils.booktabs { + border: 0px; + border-top: 2px solid; + border-bottom: 2px solid; + border-collapse: collapse; +} +table.docutils.booktabs * { + border: 0px; +} +table.docutils.booktabs th { + border-bottom: thin solid; + text-align: left; +} + +h1 tt.docutils, h2 tt.docutils, h3 tt.docutils, +h4 tt.docutils, h5 tt.docutils, h6 tt.docutils { + font-size: 100% } + +ul.auto-toc { + list-style-type: none } + +</style> +</head> +<body> +<div class="document" id="project-forecast-lines"> +<h1 class="title">Project Forecast Lines</h1> + +<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!! This file is generated by oca-gen-addon-readme !! +!! changes will be overwritten. !! +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> +<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="https://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p> +<p>This module adds forecast line to project.</p> +<p><strong>Table of contents</strong></p> +<div class="contents local topic" id="contents"> +<ul class="simple"> +<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li> +<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul> +<li><a class="reference internal" href="#authors" id="id3">Authors</a></li> +<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li> +<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li> +</ul> +</li> +</ul> +</div> +<div class="section" id="bug-tracker"> +<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1> +<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/project/issues">GitHub Issues</a>. +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 +<a class="reference external" href="https://github.com/OCA/project/issues/new?body=module:%20project_forecast_line%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p> +<p>Do not contact contributors directly about support or help with technical issues.</p> +</div> +<div class="section" id="credits"> +<h1><a class="toc-backref" href="#id2">Credits</a></h1> +<div class="section" id="authors"> +<h2><a class="toc-backref" href="#id3">Authors</a></h2> +<ul class="simple"> +<li>Camptocamp</li> +</ul> +</div> +<div class="section" id="contributors"> +<h2><a class="toc-backref" href="#id4">Contributors</a></h2> +<ul class="simple"> +<li><a class="reference external" href="https://www.camptocamp.com">Camptocamp</a><ul> +<li>Alexandre Fayolle <<a class="reference external" href="mailto:alexandre.fayolle@camptocamp.com">alexandre.fayolle@camptocamp.com</a>></li> +<li>Maksym Yankin <<a class="reference external" href="mailto:maksym.yankin@camptocamp.com">maksym.yankin@camptocamp.com</a>></li> +</ul> +</li> +</ul> +</div> +<div class="section" id="maintainers"> +<h2><a class="toc-backref" href="#id5">Maintainers</a></h2> +<p>This module is maintained by the OCA.</p> +<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a> +<p>OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.</p> +<p>This module is part of the <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line">OCA/project</a> project on GitHub.</p> +<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p> +</div> +</div> +</div> +</body> +</html> diff --git a/project_forecast_line/tests/__init__.py b/project_forecast_line/tests/__init__.py new file mode 100644 index 0000000000..93b26a9498 --- /dev/null +++ b/project_forecast_line/tests/__init__.py @@ -0,0 +1 @@ +from . import test_forecast_line diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py new file mode 100644 index 0000000000..9a1a6a9d98 --- /dev/null +++ b/project_forecast_line/tests/test_forecast_line.py @@ -0,0 +1,612 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import date + +from freezegun import freeze_time + +from odoo.tests.common import Form, TransactionCase + + +class BaseForecastLineTest(TransactionCase): + @classmethod + @freeze_time("2022-01-01") + def setUpClass(cls): + super().setUpClass() + cls.env.company.write( + { + "forecast_line_granularity": "month", + "forecast_line_horizon": 6, # months + } + ) + cls.role_developer = cls.env["forecast.role"].create({"name": "developer"}) + cls.role_consultant = cls.env["forecast.role"].create({"name": "consultant"}) + cls.role_pm = cls.env["forecast.role"].create({"name": "project manager"}) + cls.employee_dev = cls.env["hr.employee"].create({"name": "John Dev"}) + cls.user_consultant = cls.env["res.users"].create( + {"name": "John Consultant", "login": "jc@example.com"} + ) + cls.employee_consultant = cls.env["hr.employee"].create( + {"name": "John Consultant", "user_id": cls.user_consultant.id} + ) + cls.employee_pm = cls.env["hr.employee"].create({"name": "John Peem"}) + cls.env["hr.employee.forecast.role"].create( + { + "employee_id": cls.employee_dev.id, + "role_id": cls.role_developer.id, + "date_start": "2022-01-01", + "sequence": 1, + } + ) + cls.env["hr.employee.forecast.role"].create( + { + "employee_id": cls.employee_consultant.id, + "role_id": cls.role_consultant.id, + "date_start": "2022-01-01", + "sequence": 1, + } + ) + cls.env["hr.employee.forecast.role"].create( + { + "employee_id": cls.employee_pm.id, + "role_id": cls.role_pm.id, + "date_start": "2022-01-01", + "sequence": 1, + } + ) + + cls.product_dev_tm = cls.env["product.product"].create( + { + "name": "development time and material", + "detailed_type": "service", + "service_tracking": "task_in_project", + "price": 95, + "standard_price": 75, + "forecast_role_id": cls.role_developer.id, + "uom_id": cls.env.ref("uom.product_uom_hour").id, + "uom_po_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + cls.product_consultant_tm = cls.env["product.product"].create( + { + "name": "consultant time and material", + "detailed_type": "service", + "service_tracking": "task_in_project", + "price": 100, + "standard_price": 80, + "forecast_role_id": cls.role_consultant.id, + "uom_id": cls.env.ref("uom.product_uom_hour").id, + "uom_po_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + + cls.product_pm_tm = cls.env["product.product"].create( + { + "name": "pm time and material", + "detailed_type": "service", + "service_tracking": "task_in_project", + "price": 120, + "standard_price": 100, + "forecast_role_id": cls.role_consultant.id, + "uom_id": cls.env.ref("uom.product_uom_hour").id, + "uom_po_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + cls.customer = cls.env["res.partner"].create({"name": "Some Customer"}) + + +class TestForecastLineEmployee(BaseForecastLineTest): + def test_employee_main_role(self): + self.env["hr.employee.forecast.role"].create( + { + "employee_id": self.employee_consultant.id, + "role_id": self.role_developer.id, + "date_start": "2021-01-01", + "date_end": "2021-12-31", + "sequence": 0, + } + ) + self.assertEqual(self.employee_consultant.main_role_id, self.role_consultant) + + def test_employee_job_role(self): + job = self.env["hr.job"].create( + {"name": "Developer", "role_id": self.role_developer.id} + ) + employee = self.env["hr.employee"].create( + {"name": "John Dev", "job_id": job.id} + ) + self.assertEqual(employee.main_role_id, self.role_developer) + self.assertEqual(len(employee.role_ids), 1) + self.assertEqual(employee.role_ids.rate, 100) + + def test_employee_job_role_change(self): + job1 = self.env["hr.job"].create( + {"name": "Consultant", "role_id": self.role_consultant.id} + ) + job2 = self.env["hr.job"].create( + {"name": "Developer", "role_id": self.role_developer.id} + ) + employee = self.env["hr.employee"].create( + {"name": "John Dev", "job_id": job2.id} + ) + employee.job_id = job1 + self.assertEqual(employee.main_role_id, self.role_consultant) + self.assertEqual(len(employee.role_ids), 1) + self.assertEqual(employee.role_ids.rate, 100) + + @freeze_time("2022-01-01") + def test_employee_forecast(self): + lines = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("forecast_role_id", "=", self.role_consultant.id), + ("res_model", "=", "hr.employee.forecast.role"), + ] + ) + self.assertEqual(len(lines), 6) # 6 months horizon + self.assertEqual( + lines.mapped("forecast_hours"), + # number of working days in the first 6 months of 2022, no vacations + [21.0 * 8, 20.0 * 8, 23.0 * 8, 21.0 * 8, 22.0 * 8, 22.0 * 8], + ) + + @freeze_time("2022-01-01") + def test_employee_forecast_change_roles(self): + # employee becomes 50% consultant, 50% PM on Feb 1st + roles = self.employee_consultant.role_ids + roles.write({"date_end": "2022-01-31"}) + lines = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("forecast_role_id", "=", self.role_consultant.id), + ("res_model", "=", "hr.employee.forecast.role"), + ] + ) + self.assertEqual(len(lines), 1) # 100% consultant role now ends on 31/01 + self.assertEqual(lines.forecast_hours, 21.0 * 8) + self.env["hr.employee.forecast.role"].create( + [ + { + "employee_id": self.employee_consultant.id, + "role_id": self.role_consultant.id, + "date_start": "2022-02-01", + "sequence": 1, + "rate": 50, + }, + { + "employee_id": self.employee_consultant.id, + "role_id": self.role_pm.id, + "date_start": "2022-02-01", + "sequence": 2, + "rate": 50, + }, + ] + ) + lines = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("forecast_role_id", "=", self.role_consultant.id), + ] + ) + self.assertEqual(len(lines), 6) # 6 months horizon + self.assertEqual( + lines.mapped("forecast_hours"), + # number of days in the first 6 months of 2022 + [ + 21.0 * 8, + 20.0 * 8 / 2, + 23.0 * 8 / 2, + 21.0 * 8 / 2, + 22.0 * 8 / 2, + 22.0 * 8 / 2, + ], + ) + + @freeze_time("2022-01-01 12:00:00") + def test_forecast_with_calendar(self): + calendar = self.employee_dev.resource_calendar_id + self.env["resource.calendar.leaves"].create( + { + "name": "Easter monday", + "calendar_id": calendar.id, + "date_from": "2022-04-18 00:00:00", + "date_to": "2022-04-19 00:00:00", # Easter + "time_type": "leave", + } + ) + lines = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_dev.id), + ("forecast_role_id", "=", self.role_developer.id), + ("res_model", "=", "hr.employee.forecast.role"), + ] + ) + self.assertEqual(len(lines), 6) # 6 months horizon + self.assertEqual( + lines.mapped("forecast_hours"), + # number of days in the first 6 months of 2022, minus easter in April + [21.0 * 8, 20.0 * 8, 23.0 * 8, (21.0 - 1) * 8, 22.0 * 8, 22.0 * 8], + ) + + +class TestForecastLineSales(BaseForecastLineTest): + @freeze_time("2022-01-01") + def test_draft_sale_order_creates_negative_forecast_forecast(self): + with Form(self.env["sale.order"]) as form: + form.partner_id = self.customer + form.date_order = "2022-01-10 08:00:00" + form.default_forecast_date_start = "2022-02-07" + form.default_forecast_date_end = "2022-02-20" + with form.order_line.new() as line: + line.product_id = self.product_dev_tm + line.product_uom_qty = 10 # 1 FTE sold + line.product_uom = self.env.ref("uom.product_uom_day") + so = form.save() + line = so.order_line[0] + self.assertEqual(line.forecast_date_start, date(2022, 2, 7)) + self.assertEqual(line.forecast_date_end, date(2022, 2, 20)) + forecast_lines = self.env["forecast.line"].search( + [ + ("sale_line_id", "=", line.id), + ("res_model", "=", "sale.order.line"), + ] + ) + self.assertEqual(len(forecast_lines), 1) # 10 days on 2022-02-01 to 2022-02-10 + self.assertEqual(forecast_lines.type, "forecast") + self.assertEqual( + forecast_lines.forecast_role_id, + self.product_dev_tm.forecast_role_id, + ) + self.assertEqual(forecast_lines.forecast_hours, -10 * 8) + self.assertEqual(forecast_lines.cost, -10 * 8 * 75) + self.assertEqual(forecast_lines.date_from, date(2022, 2, 1)) + self.assertEqual(forecast_lines.date_to, date(2022, 2, 28)) + + @freeze_time("2022-01-01") + def test_draft_sale_order_without_dates_no_forecast(self): + """a draft sale order with no dates on the line does not create forecast""" + with Form(self.env["sale.order"]) as form: + form.partner_id = self.customer + form.date_order = "2022-01-10 08:00:00" + form.default_forecast_date_start = "2022-02-07" + form.default_forecast_date_end = False + with form.order_line.new() as line: + line.product_id = self.product_dev_tm + line.product_uom_qty = 10 # 1 FTE sold + line.product_uom = self.env.ref("uom.product_uom_day") + so = form.save() + line = so.order_line[0] + self.assertEqual(line.forecast_date_start, date(2022, 2, 7)) + self.assertEqual(line.forecast_date_end, False) + forecast_lines = self.env["forecast.line"].search( + [ + ("sale_line_id", "=", line.id), + ("res_model", "=", "sale.order.line"), + ] + ) + self.assertFalse(forecast_lines) + + @freeze_time("2022-01-01") + def test_draft_sale_order_forecast_spread(self): + with Form(self.env["sale.order"]) as form: + form.partner_id = self.customer + form.date_order = "2022-01-10 08:00:00" + form.default_forecast_date_start = "2022-02-07" + form.default_forecast_date_end = "2022-04-17" + with form.order_line.new() as line: + line.product_id = self.product_dev_tm + line.product_uom_qty = 100 # sell 2 FTE + line.product_uom = self.env.ref("uom.product_uom_day") + + so = form.save() + line = so.order_line[0] + self.assertEqual(line.forecast_date_start, date(2022, 2, 7)) + self.assertEqual(line.forecast_date_end, date(2022, 4, 17)) + forecast_lines = self.env["forecast.line"].search( + [ + ("sale_line_id", "=", line.id), + ("res_model", "=", "sale.order.line"), + ] + ) + self.assertEqual(len(forecast_lines), 3) + daily_ratio = 2 * 8 # 2 FTE * 8h days + self.assertAlmostEqual( + forecast_lines[0].forecast_hours, + -1 * daily_ratio * 16, # 16 worked days between 2022 Feb 7 and Feb 28 + ) + self.assertAlmostEqual( + forecast_lines[1].forecast_hours, + -1 * daily_ratio * 23, # 23 worked days in march 2022 + ) + self.assertAlmostEqual( + forecast_lines[2].forecast_hours, + -1 * daily_ratio * 11, # 11 worked day between april 1 and 17 2022 + ) + self.assertEqual( + forecast_lines.mapped("date_from"), + [date(2022, 2, 1), date(2022, 3, 1), date(2022, 4, 1)], + ) + self.assertEqual( + forecast_lines.mapped("date_to"), + [date(2022, 2, 28), date(2022, 3, 31), date(2022, 4, 30)], + ) + + @freeze_time("2022-01-01") + def test_confirm_order_sale_order_no_forecast_line(self): + with Form(self.env["sale.order"]) as form: + form.partner_id = self.customer + form.date_order = "2022-01-10 08:00:00" + form.default_forecast_date_start = "2022-02-14" + form.default_forecast_date_end = "2022-04-14" + with form.order_line.new() as line: + line.product_id = self.product_dev_tm + line.product_uom_qty = 60 + line.product_uom = self.env.ref("uom.product_uom_day") + + so = form.save() + so.action_confirm() + line = so.order_line[0] + forecast_lines = self.env["forecast.line"].search( + [ + ("sale_line_id", "=", line.id), + ("res_model", "=", "sale.order.line"), + ] + ) + self.assertFalse(forecast_lines) + + @freeze_time("2022-01-01") + def test_confirm_order_sale_order_create_project_task_with_forecast_line(self): + with Form(self.env["sale.order"]) as form: + form.partner_id = self.customer + form.date_order = "2022-01-10 08:00:00" + form.default_forecast_date_start = "2022-02-14" + form.default_forecast_date_end = "2022-04-17" + with form.order_line.new() as line: + line.product_id = self.product_dev_tm + line.product_uom_qty = 45 * 2 # 2 FTE + line.product_uom = self.env.ref("uom.product_uom_day") + so = form.save() + so.action_confirm() + line = so.order_line[0] + task = self.env["project.task"].search([("sale_line_id", "=", line.id)]) + forecast_lines = self.env["forecast.line"].search( + [("res_id", "=", task.id), ("res_model", "=", "project.task")] + ) + self.assertEqual(len(forecast_lines), 3) + self.assertEqual(forecast_lines.mapped("forecast_role_id"), self.role_developer) + daily_ratio = 8 * 2 # 2 FTE + self.assertAlmostEqual( + forecast_lines[0].forecast_hours, + -1 * daily_ratio * 11, # 11 working days on 2022-02-14 -> 2022-02-28 + ) + self.assertAlmostEqual( + forecast_lines[1].forecast_hours, + -1 * daily_ratio * 23, # 23 working days on 2022-03-01 -> 2022-03-31 + ) + self.assertAlmostEqual( + forecast_lines[2].forecast_hours, + -1 * daily_ratio * 11, # 11 working days on 2022-04-01 -> 2022-04-17 + ) + + +class TestForecastLineTimesheet(BaseForecastLineTest): + def test_timesheet_forecast_lines(self): + with freeze_time("2022-01-01"): + with Form(self.env["sale.order"]) as form: + form.partner_id = self.customer + form.date_order = "2022-01-10 08:00:00" + form.default_forecast_date_start = "2022-02-14" + form.default_forecast_date_end = "2022-04-17" + with form.order_line.new() as line: + line.product_id = self.product_dev_tm + line.product_uom_qty = ( + 45 * 2 + ) # 45 working days in the period, sell 2 FTE + line.product_uom = self.env.ref("uom.product_uom_day") + so = form.save() + so.action_confirm() + + with freeze_time("2022-02-14"): + line = so.order_line[0] + task = self.env["project.task"].search([("sale_line_id", "=", line.id)]) + # timesheet 1d + self.env["account.analytic.line"].create( + { + "employee_id": self.employee_dev.id, + "task_id": task.id, + "project_id": task.project_id.id, + "unit_amount": 8, + } + ) + forecast_lines = self.env["forecast.line"].search( + [("res_id", "=", task.id), ("res_model", "=", "project.task")] + ) + self.assertEqual(len(forecast_lines), 3) + daily_ratio = (45 * 2 - 1) * 8 / 45 + self.assertAlmostEqual( + forecast_lines[0].forecast_hours, -1 * daily_ratio * 11 + ) + self.assertAlmostEqual( + forecast_lines[1].forecast_hours, -1 * daily_ratio * 23 + ) + self.assertAlmostEqual( + forecast_lines[2].forecast_hours, -1 * daily_ratio * 11 + ) + self.assertEqual( + forecast_lines.mapped("date_from"), + [date(2022, 2, 1), date(2022, 3, 1), date(2022, 4, 1)], + ) + self.assertEqual( + forecast_lines.mapped("date_to"), + [date(2022, 2, 28), date(2022, 3, 31), date(2022, 4, 30)], + ) + + def test_timesheet_forecast_lines_cron(self): + """check recomputation of forecast lines of tasks even if we don"t TS""" + self.test_timesheet_forecast_lines() + with freeze_time("2022-03-10"): + self.env["forecast.line"]._cron_recompute_all() + forecast_lines = self.env["forecast.line"].search( + [("res_model", "=", "project.task")] + ) + self.assertEqual(len(forecast_lines), 2) + daily_ratio = ( + 8 + * (45 * 2 - 1) + / 27 # 27 worked days between 2022-03-10 and 2022-04-17 + ) + self.assertAlmostEqual( + forecast_lines[0].forecast_hours, + -1 + * daily_ratio + * 16, # 16 worked days between 2022-03-10 and 2022-03-31 + ) + self.assertAlmostEqual( + forecast_lines[1].forecast_hours, + -1 + * daily_ratio + * 11, # 11 worked days between 2022-04-01 and 2022-04-17 + ) + self.assertEqual( + forecast_lines.mapped("date_from"), + [date(2022, 3, 1), date(2022, 4, 1)], + ) + self.assertEqual( + forecast_lines.mapped("date_to"), + [date(2022, 3, 31), date(2022, 4, 30)], + ) + + +class TestForecastLineProject(BaseForecastLineTest): + @classmethod + @freeze_time("2022-01-01") + def setUpClass(cls): + super().setUpClass() + # for this test, we use a daily granularity + cls.env.company.write( + { + "forecast_line_granularity": "day", + "forecast_line_horizon": 2, # months + } + ) + + def test_task_forecast_lines_consolidated_forecast(self): + with freeze_time("2022-01-01"): + employee_forecast = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("date_from", "=", "2022-02-14"), + ] + ) + self.assertEqual(len(employee_forecast), 1) + project = self.env["project.project"].create({"name": "TestProject"}) + # 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": "Task1", + "project_id": project.id, + "forecast_role_id": self.role_consultant.id, + "forecast_date_planned_start": "2022-02-14", + "forecast_date_planned_end": "2022-02-14", + "planned_hours": 6, + } + ) + task.remaining_hours = 6 + task.user_ids = self.user_consultant + forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) + 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.assertEqual( + forecast.employee_resource_forecast_line_id.consolidated_forecast, + 0.25, + ) + + def test_task_forecast_lines_consolidated_forecast_overallocation(self): + with freeze_time("2022-01-01"): + employee_forecast = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("date_from", "=", "2022-02-14"), + ] + ) + self.assertEqual(len(employee_forecast), 1) + project = self.env["project.project"].create({"name": "TestProject"}) + # 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": "Task1", + "project_id": project.id, + "forecast_role_id": self.role_consultant.id, + "forecast_date_planned_start": "2022-02-14", + "forecast_date_planned_end": "2022-02-14", + "planned_hours": 8, + } + ) + task.remaining_hours = 10 + task.user_ids = self.user_consultant + forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) + self.assertEqual(len(forecast), 1) + # using assertEqual on purpose here + self.assertEqual(forecast.forecast_hours, -10.0) + self.assertEqual(forecast.consolidated_forecast, 1.25) + self.assertEqual( + forecast.employee_resource_forecast_line_id.consolidated_forecast, + -0.25, + ) + + def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks( + self, + ): + with freeze_time("2022-01-01"): + employee_forecast = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("date_from", "=", "2022-02-14"), + ] + ) + self.assertEqual(len(employee_forecast), 1) + project = self.env["project.project"].create({"name": "TestProject"}) + # set project in stage "in progress" to get confirmed forecast + project.stage_id = self.env.ref("project.project_project_stage_1") + task1 = self.env["project.task"].create( + { + "name": "Task1", + "project_id": project.id, + "forecast_role_id": self.role_consultant.id, + "forecast_date_planned_start": "2022-02-14", + "forecast_date_planned_end": "2022-02-14", + "planned_hours": 8, + } + ) + task1.remaining_hours = 10 + task1.user_ids = self.user_consultant + forecast1 = self.env["forecast.line"].search([("task_id", "=", task1.id)]) + self.assertEqual(len(forecast1), 1) + task2 = self.env["project.task"].create( + { + "name": "Task2", + "project_id": project.id, + "forecast_role_id": self.role_consultant.id, + "forecast_date_planned_start": "2022-02-14", + "forecast_date_planned_end": "2022-02-14", + "planned_hours": 4, + } + ) + task2.remaining_hours = 4 + task2.user_ids = self.user_consultant + forecast2 = self.env["forecast.line"].search([("task_id", "=", task2.id)]) + # using assertEqual on purpose here + self.assertEqual( + 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, + ) diff --git a/project_forecast_line/views/forecast_line_views.xml b/project_forecast_line/views/forecast_line_views.xml new file mode 100644 index 0000000000..7b92e72d75 --- /dev/null +++ b/project_forecast_line/views/forecast_line_views.xml @@ -0,0 +1,154 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="view_forecast_line_search" model="ir.ui.view"> + <field name="model">forecast.line</field> + <field name="arch" type="xml"> + <search> + <field name="name" /> + <field name="forecast_role_id" /> + <field name="project_id" /> + <field name="employee_id" /> + <field name="company_id" groups="base.group_multi_company" /> + <filter + string="Forecast" + domain="[('type', '=', 'forecast')]" + name="forecast_forcast_filter" + /> + <filter + string="Confirmed" + domain="[('type', '=', 'confirmed')]" + name="forecast_confirmed_filter" + /> + <group expand="0" string="Group By"> + <filter + string="Project" + name="project" + domain="[]" + context="{'group_by': 'project_id'}" + /> + <filter + string="Role" + name="role" + domain="[]" + context="{'group_by': 'forecast_role_id'}" + /> + <filter + string="Date from" + name="date_from" + domain="[]" + context="{'group_by':'date_from:month'}" + /> + <filter + string="Employee" + name="employee" + domain="[]" + context="{'group_by': 'employee_id'}" + /> + <filter + string="Model" + name="model" + domain="[]" + context="{'group_by': 'res_model'}" + /> + </group> + </search> + </field> + </record> + + <record id="view_forecast_line_list" model="ir.ui.view"> + <field name="model">forecast.line</field> + <field name="arch" type="xml"> + <tree> + <field name="name" /> + <field name="date_from" /> + <field name="date_to" /> + <field name="forecast_hours" /> + <field name="cost" /> + <field name="consolidated_forecast" /> + <field name="forecast_role_id" /> + <field name="currency_id" invisible="1" /> + </tree> + </field> + </record> + <record id="view_forecast_line_graph" model="ir.ui.view"> + <field name="model">forecast.line</field> + <field name="arch" type="xml"> + <graph type="bar" stacked="0"> + <field name="date_from" type="row" /> + <field name="forecast_role_id" type="col" /> + <field name="consolidated_forecast" type="measure" /> + <field name="cost" type="measure" /> + <field name="forecast_hours" type="measure" /> + <field name="currency_id" invisible="1" /> + </graph> + </field> + </record> + <record id="view_forecast_line_graph_stacked" model="ir.ui.view"> + <field name="model">forecast.line</field> + <field name="priority" eval="20" /> + <field name="arch" type="xml"> + <graph type="bar" stacked="1"> + <field name="date_from" type="row" /> + <field name="employee_id" type="col" /> + <field name="project_id" type="col" /> + <field name="consolidated_forecast" type="measure" /> + </graph> + </field> + </record> + <record id="view_forecast_line_pivot" model="ir.ui.view"> + <field name="model">forecast.line</field> + <field name="arch" type="xml"> + <pivot> + <field name="date_from" type="col" /> + <field name="forecast_role_id" type="row" /> + <field name="consolidated_forecast" type="measure" /> + <field name="cost" type="measure" /> + <field name="forecast_hours" type="measure" /> + <field name="currency_id" invisible="1" /> + </pivot> + </field> + </record> + + <record id="action_forecast_lines" model="ir.actions.act_window"> + <field name="name">Forecast</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">forecast.line</field> + <field name="view_mode">tree,graph,pivot</field> + </record> + <record id="action_forecast_lines_consolidated" model="ir.actions.act_window"> + <field name="name">Forecast (Consolidated)</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">forecast.line</field> + <field name="view_mode">graph,pivot,tree</field> + <field name="view_id" ref="view_forecast_line_graph_stacked" /> + <field name="search_view_id" ref="view_forecast_line_search" /> + <field name="domain">[('employee_id', '!=', False)]</field> + <field + name="context" + >{'graph_groupbys': ['date_from:month', 'project_id']}</field> + </record> + + <menuitem + id="forecast_menu_root" + name="Forecast" + sequence="75" + groups="base.group_user" + action="action_forecast_lines" + web_icon="project_forecast_line,static/description/icon.png" + /> + <menuitem + id="menu_forecast_config" + name="Configuration" + parent="forecast_menu_root" + sequence="100" + groups="hr.group_hr_user" + /> + <menuitem + id="menu_forecast_line_consolidated" + parent="forecast_menu_root" + sequence="10" + name="Forecast" + action="action_forecast_lines_consolidated" + /> + +</odoo> diff --git a/project_forecast_line/views/forecast_role_views.xml b/project_forecast_line/views/forecast_role_views.xml new file mode 100644 index 0000000000..a3b829945a --- /dev/null +++ b/project_forecast_line/views/forecast_role_views.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="view_forecast_role_search" model="ir.ui.view"> + <field name="model">forecast.role</field> + <field name="arch" type="xml"> + <search> + <field name="name" /> + </search> + </field> + </record> + + <record id="view_forecast_role_list" model="ir.ui.view"> + <field name="model">forecast.role</field> + <field name="arch" type="xml"> + <tree> + <field name="name" /> + </tree> + </field> + </record> + <record id="view_forecast_role_form" model="ir.ui.view"> + <field name="model">forecast.role</field> + <field name="arch" type="xml"> + <form> + <sheet> + <group> + <field name="name" /> + <field name="description" /> + </group> + </sheet> + </form> + </field> + </record> + <record id="action_forecast_roles" model="ir.actions.act_window"> + <field name="name">Forecast Roles</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">forecast.role</field> + <field name="view_mode">tree,form</field> + </record> + + <menuitem + id="menu_forecast_role" + parent="menu_forecast_config" + sequence="20" + name="Forecast Roles" + action="action_forecast_roles" + /> + +</odoo> diff --git a/project_forecast_line/views/hr_employee_views.xml b/project_forecast_line/views/hr_employee_views.xml new file mode 100644 index 0000000000..afbad9594a --- /dev/null +++ b/project_forecast_line/views/hr_employee_views.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="view_employee_form" model="ir.ui.view"> + <field name="model">hr.employee</field> + <field name="inherit_id" ref="hr.view_employee_form" /> + <field name="arch" type="xml"> + <xpath expr="//page[@name='hr_settings']/group[1]" position="inside"> + <group string="Forecast Roles" name="forecast_roles"> + <field name="role_ids"> + <tree editable="bottom"> + <field name="sequence" widget="handle" /> + <field name="role_id" /> + <field name="date_start" /> + <field name="date_end" /> + <field name="rate" /> + </tree> + </field> + </group> + </xpath> + </field> + </record> + + <record id="view_hr_job_form" model="ir.ui.view"> + <field name="model">hr.job</field> + <field name="inherit_id" ref="hr.view_hr_job_form" /> + <field name="arch" type="xml"> + <xpath expr="//sheet/div[hasclass('oe_title')]" position="after"> + <group><group><field name="role_id" /></group></group> + </xpath> + </field> + </record> +</odoo> diff --git a/project_forecast_line/views/product_views.xml b/project_forecast_line/views/product_views.xml new file mode 100644 index 0000000000..66db9d076c --- /dev/null +++ b/project_forecast_line/views/product_views.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="product_template_form" model="ir.ui.view"> + <field name="model">product.template</field> + <field name="inherit_id" ref="product.product_template_only_form_view" /> + <field name="arch" type="xml"> + <field name="barcode" position="after"> + <field name="forecast_role_id" /> + </field> + </field> + </record> +</odoo> diff --git a/project_forecast_line/views/project_project_stage_views.xml b/project_forecast_line/views/project_project_stage_views.xml new file mode 100644 index 0000000000..c93ccc0b17 --- /dev/null +++ b/project_forecast_line/views/project_project_stage_views.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="project_project_stage_view_tree" model="ir.ui.view"> + <field name="model">project.project.stage</field> + <field name="inherit_id" ref="project.project_project_stage_view_tree" /> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="forecast_line_type" /> + </field> + </field> + </record> + <record id="project_project_stage_view_form_quick_create" model="ir.ui.view"> + <field name="model">project.project.stage</field> + <field + name="inherit_id" + ref="project.project_project_stage_view_form_quick_create" + /> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="forecast_line_type" /> + </field> + </field> + </record> + <record id="project_project_stage_view_form" model="ir.ui.view"> + <field name="inherit_id" ref="project.project_project_stage_view_form" /> + <field name="model">project.project.stage</field> + <field name="arch" type="xml"> + <field name="active" position="after"> + <field name="forecast_line_type" /> + </field> + </field> + </record> + +</odoo> diff --git a/project_forecast_line/views/project_task_views.xml b/project_forecast_line/views/project_task_views.xml new file mode 100644 index 0000000000..3b45b166f4 --- /dev/null +++ b/project_forecast_line/views/project_task_views.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="view_task_form" model="ir.ui.view"> + <field name="model">project.task</field> + <field name="inherit_id" ref="project.view_task_form2" /> + <field name="arch" type="xml"> + <field name="parent_id" position="before"> + <field name="forecast_role_id" /> + </field> + <field name="analytic_account_id" position="before"> + <field name="forecast_date_planned_start" /> + <field name="forecast_date_planned_end" /> + </field> + </field> + </record> +</odoo> diff --git a/project_forecast_line/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml new file mode 100644 index 0000000000..e273587afb --- /dev/null +++ b/project_forecast_line/views/res_config_settings_views.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="res_config_settings_view_form" model="ir.ui.view"> + <field name="name">res.config.settings.view.form.inherit.forecast</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="base.res_config_settings_view_form" /> + <field name="arch" type="xml"> + <xpath expr="//div[hasclass('settings')]" position="inside"> + <div + class="app_settings_block" + data-string="Forecast" + string="Forecast" + data-key="project_forecast_line" + groups="hr.group_hr_user" + > + <h2>Forecast Management</h2> + <div class="row mt16 o_settings_container" id="forecast_management"> + <div + class="col-12 col-lg-6 o_setting_box" + id="forecast_line_settings" + > + <div class="o_setting_left_pane"> + <field name="group_forecast_line_on_quotation" /> + </div> + <div class="o_setting_right_pane"> + <label for="group_forecast_line_on_quotation" /> + <div class="text-muted"> + Allow to see forecast dates on quotations + </div> + <div class="content-group"> + <div class="mt8"> + <label for="forecast_line_granularity" /> + <div class="text-muted"> + Periodicity of the forecast that will be generated + </div> + <field + name="forecast_line_granularity" + required="1" + class="o_light_label" + /> + </div> + <div class="mt8"> + <label for="forecast_line_horizon" /> + <div class="text-muted"> + Number of month for the forecast planning + </div> + <field + name="forecast_line_horizon" + required="1" + class="o_field_integer o_field_number o_field_widget o_input oe_inline col-lg-2" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </xpath> + </field> + </record> + <record id="forecast_config_settings_action" model="ir.actions.act_window"> + <field name="name">Settings</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">res.config.settings</field> + <field name="view_mode">form</field> + <field name="target">inline</field> + <field name="context">{'module': 'forecast', 'bin_size': False}</field> + </record> + + <menuitem + id="forecast_config_settings_menu_action" + name="Settings" + parent="menu_forecast_config" + sequence="0" + action="forecast_config_settings_action" + groups="base.group_system" + /> +</odoo> diff --git a/project_forecast_line/views/sale_order_views.xml b/project_forecast_line/views/sale_order_views.xml new file mode 100644 index 0000000000..bb6b0149ca --- /dev/null +++ b/project_forecast_line/views/sale_order_views.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="view_order_form" model="ir.ui.view"> + <field name="model">sale.order</field> + <field name="inherit_id" ref="sale.view_order_form" /> + <field name="arch" type="xml"> + <field name="date_order" position="after"> + <field + name="default_forecast_date_start" + groups="project_forecast_line.group_forecast_line_on_quotation" + /> + <field + name="default_forecast_date_end" + groups="project_forecast_line.group_forecast_line_on_quotation" + /> + </field> + <xpath + expr="//field[@name='order_line']/form//field[@name='product_id']" + position="before" + > + <field + name="forecast_date_start" + groups="project_forecast_line.group_forecast_line_on_quotation" + /> + <field + name="forecast_date_end" + groups="project_forecast_line.group_forecast_line_on_quotation" + /> + </xpath> + <xpath + expr="//field[@name='order_line']/tree//field[@name='customer_lead']" + position="after" + > + <field + name="forecast_date_start" + optional="show" + groups="project_forecast_line.group_forecast_line_on_quotation" + /> + <field + name="forecast_date_end" + optional="show" + groups="project_forecast_line.group_forecast_line_on_quotation" + /> + </xpath> + </field> + </record> + +</odoo> From fa3d72ae576ab3aac37bd621dc958cb1950d9afc Mon Sep 17 00:00:00 2001 From: oca-ci <oca-ci@odoo-community.org> Date: Wed, 17 Aug 2022 06:25:18 +0000 Subject: [PATCH 02/33] [UPD] Update project_forecast_line.pot --- project_forecast_line/i18n/project_forecast_line.pot | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/project_forecast_line/i18n/project_forecast_line.pot b/project_forecast_line/i18n/project_forecast_line.pot index 3f97478e10..8d1a46263f 100644 --- a/project_forecast_line/i18n/project_forecast_line.pot +++ b/project_forecast_line/i18n/project_forecast_line.pot @@ -4,10 +4,8 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 15.0+e\n" +"Project-Id-Version: Odoo Server 15.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-08-09 11:54+0000\n" -"PO-Revision-Date: 2022-08-09 11:54+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" From 5a2aacf781f1c808a35ad24fc7683a17cdcb02ce Mon Sep 17 00:00:00 2001 From: OCA-git-bot <oca-git-bot@odoo-community.org> Date: Wed, 17 Aug 2022 06:28:49 +0000 Subject: [PATCH 03/33] [UPD] README.rst --- project_forecast_line/README.rst | 77 ++++++++++++++++++- .../static/description/index.html | 21 +++-- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst index c275fb4dbf..47a72f7f17 100644 --- a/project_forecast_line/README.rst +++ b/project_forecast_line/README.rst @@ -1 +1,76 @@ -TO BE GENERATED BY OCA BOT +====================== +Project Forecast Lines +====================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github + :target: https://github.com/OCA/project/tree/15.0/project_forecast_line + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/140/15.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to plan your resources using forecast lines. +For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees. +Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed". + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues <https://github.com/OCA/project/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 <https://github.com/OCA/project/issues/new?body=module:%20project_forecast_line%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp SA + +Contributors +~~~~~~~~~~~~ + +* Alexandre Fayolle <alexandre.fayolle@camptocamp.com> +* Maksym Yankin <maksym.yankin@camptocamp.com> + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/project <https://github.com/OCA/project/tree/15.0/project_forecast_line>`_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html index a57a3348ed..9e96d47fcd 100644 --- a/project_forecast_line/static/description/index.html +++ b/project_forecast_line/static/description/index.html @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="utf-8" ?> -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> -<html xmlns="https://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> -<meta https-equiv="Content-Type" content="text/html; charset=utf-8" /> -<meta name="generator" content="Docutils 0.15.1: https://docutils.sourceforge.net/" /> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> +<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" /> <title>Project Forecast Lines</title> <style type="text/css"> @@ -14,7 +14,7 @@ Default cascading style sheet for the HTML output of Docutils. -See https://docutils.sf.net/docs/howto/html-stylesheets.html for how to +See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to customize this style sheet. */ @@ -367,8 +367,10 @@ <h1 class="title">Project Forecast Lines</h1> !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="https://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p> -<p>This module adds forecast line to project.</p> +<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p> +<p>This module allows to plan your resources using forecast lines. +For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will “consume” the work time capacity of the employees. +Forecast lines also come in two states “forecast” or “confirmed”, depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type “forecast”, whereas tasks for project which are in a running state create lines with type “confirmed”.</p> <p><strong>Table of contents</strong></p> <div class="contents local topic" id="contents"> <ul class="simple"> @@ -394,18 +396,15 @@ <h1><a class="toc-backref" href="#id2">Credits</a></h1> <div class="section" id="authors"> <h2><a class="toc-backref" href="#id3">Authors</a></h2> <ul class="simple"> -<li>Camptocamp</li> +<li>Camptocamp SA</li> </ul> </div> <div class="section" id="contributors"> <h2><a class="toc-backref" href="#id4">Contributors</a></h2> <ul class="simple"> -<li><a class="reference external" href="https://www.camptocamp.com">Camptocamp</a><ul> <li>Alexandre Fayolle <<a class="reference external" href="mailto:alexandre.fayolle@camptocamp.com">alexandre.fayolle@camptocamp.com</a>></li> <li>Maksym Yankin <<a class="reference external" href="mailto:maksym.yankin@camptocamp.com">maksym.yankin@camptocamp.com</a>></li> </ul> -</li> -</ul> </div> <div class="section" id="maintainers"> <h2><a class="toc-backref" href="#id5">Maintainers</a></h2> From c99df8b9d38b67c3dda5ed1ad8785ec965b51eec Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Wed, 17 Aug 2022 14:47:32 +0200 Subject: [PATCH 04/33] project_forecast_line: improve documentation --- project_forecast_line/README.rst | 76 +++++++++++++++- .../readme/CONFIGURATION.rst | 20 +++++ project_forecast_line/readme/DESCRIPTION.rst | 28 +++++- project_forecast_line/readme/USAGE.rst | 62 +++++++++++++ .../static/description/index.html | 87 ++++++++++++++++--- 5 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 project_forecast_line/readme/CONFIGURATION.rst create mode 100644 project_forecast_line/readme/USAGE.rst diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst index 47a72f7f17..87c7d75b27 100644 --- a/project_forecast_line/README.rst +++ b/project_forecast_line/README.rst @@ -26,14 +26,86 @@ Project Forecast Lines |badge1| |badge2| |badge3| |badge4| |badge5| This module allows to plan your resources using forecast lines. -For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees. -Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed". + +For each employee of the company, the module will generate forecast line +records with a positive capacity based on their working time schedules. Then, +tasks assigned to employees will generate forecast lines with a negative +capacity which will "consume" the work time capacity of the employees. + +The idea is that you can then see the work capacity and scheduled work of +people by summing the "forecasts" per time period. If you have more resources +(positive forecast) than work (negative forecast) you will have a positive net +sum. Otherwise you are in trouble and need to recruit or reschedule your +work. Another way to use the report is checking when the work capacity of a +department becomes positive (or high enough) in order to provide you potential +customers with an estimate of when a project would be able to start. + +Forecast lines also come in two states "forecast" or "confirmed", depending on +whether the consumption is confirmed or not. For instance, holidays requests +and sales quotation lines create lines of type "forecast", whereas tasks for +project which are in a running state create lines with type "confirmed". + **Table of contents** .. contents:: :local: +Usage +===== + +Forecast lines have the following data: + +* Forecast hours: it is positive for resources (employees) and negative for + things which consume time + +* From and To date which are the beginning and ending of the period of the + capacity + +* Consolidated forecast: this is a computed field, which is computed as follows: + + * for costs (project tasks for instance) we take the absolute value of the + forecast hours (so it is a positive number) + + * for resources (employee capacity for a period), we take the capacity and + substract all the costs for that employee on the same period. So it will be + positive if the employee still has some free time, and negative if he is + overloaded with work. + + +Objects creating forecast lines: + +* employees with a forecast role will create forecast line with a positive + capacity and type "confirmed" for each day on which they work. This + information comes from their work calendar, and the different roles that are + linked to the employee. + +* draft sale orders (if enabled in the settings) will create forecast lines of + type "forecast" for each sale order line having a product with a forecast + role and start and end dates. The forecast hours are negative + +* confirmed sale orders don't create forecast lines. This is handled by the + tasks created at the confirmation of the sale order + +* project tasks create forecast lines if they have a linked role and start/end + date. The type of the line will depend on the related project's stage. The + forecast quantity is based on the remaining time of the task, which is spread + on the work days of the planned start and end date of the task. If the + current date is in the middle of the planned duration of the task, it is used + as the start date. If the planned end date is in the past the task does not + generate forecast lines (and you need to fix your planning). In case multiple + employees are assigned to the task the forecast is split equally between + them. + +* holiday requests create negative forecast lines with type "forecast" when + they are pending manager validation. + +* Validated holiday requests do not generate forecast lines, as they alter the + work calendar of the employee: the employee will not have a positive line + associated to his leave days. + + + Bug Tracker =========== diff --git a/project_forecast_line/readme/CONFIGURATION.rst b/project_forecast_line/readme/CONFIGURATION.rst new file mode 100644 index 0000000000..c30fb59df9 --- /dev/null +++ b/project_forecast_line/readme/CONFIGURATION.rst @@ -0,0 +1,20 @@ +In the settings, you'll find a Forecast Management section where you can configure + +* the granularity of the forecast (day / week / month) +* the horizon of the forecast (number of months) +* if we want to manage forecast from quotations + +Then you can configure in the Forecast application the forecast roles. These are +roles than can be linked to products and to employees, for instance "project +manager" or "consultant". + +If you want to use the forecast on sales -> forecast on tasks chain, you should +configure the service products that will be used on the sale order to give them +a forecast role. + +Finally, you need to set roles on employees. You can set this on the Jobs, and +when an employee is assigned a job, the job's role will be pushed to the +employee. Or you can just set the role on the employee. The roles of employees +have start and end dates, so you can manage people who will join the company in +the future or people's internal job changes. You can also set a rate and handle +people wearing multiple hats. diff --git a/project_forecast_line/readme/DESCRIPTION.rst b/project_forecast_line/readme/DESCRIPTION.rst index b2e45eba26..d86f5d98b8 100644 --- a/project_forecast_line/readme/DESCRIPTION.rst +++ b/project_forecast_line/readme/DESCRIPTION.rst @@ -1,3 +1,27 @@ This module allows to plan your resources using forecast lines. -For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees. -Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed". + +For each employee of the company, the module will generate forecast line +records with a positive capacity based on their working time schedules. Then, +tasks assigned to employees will generate forecast lines with a negative +capacity which will "consume" the work time capacity of the employees. + +The idea is that you can then see the work capacity and scheduled work of +people by summing the "forecasts" per time period. If you have more resources +(positive forecast) than work (negative forecast) you will have a positive net +sum. Otherwise you are in trouble and need to recruit or reschedule your +work. Another way to use the report is checking when the work capacity of a +department becomes positive (or high enough) in order to provide you potential +customers with an estimate of when a project would be able to start. + +Forecast lines also come in two states "forecast" or "confirmed", depending on +whether the consumption is confirmed or not. For instance, holidays requests +and sales quotation lines create lines of type "forecast", whereas tasks for +project which are in a running state create lines with type "confirmed". + +To get the best experience using the Forecast application you may want to install: + +* project_forecast_line_holidays_public module which takes public holidays into + account during forecast lines creation + +* project_forecast_line_bokeh_chart module which improves the reports of + project_forecast_line module by using the bokeh widget available in OCA/web diff --git a/project_forecast_line/readme/USAGE.rst b/project_forecast_line/readme/USAGE.rst new file mode 100644 index 0000000000..da1496b3d8 --- /dev/null +++ b/project_forecast_line/readme/USAGE.rst @@ -0,0 +1,62 @@ +Forecast lines have the following data: + +* Forecast hours: it is positive for resources (employees) and negative for + things which consume time (project tasks, for instance) + +* From and To date which are the beginning and ending of the period of the + capacity + +* Consolidated forecast: this is a computed field, which is computed as follows: + + * for costs (project tasks for instance) we take the absolute value of the + forecast hours (so it is a positive number) + + * for resources (employee capacity for a period), we take the capacity and + substract all the costs for that employee on the same period. So it will be + positive if the employee still has some free time, and negative if he is + overloaded with work. + + * this consolidated forecast is currently converted to days to ease + readability of the forecast report + + +Objects creating forecast lines: + +* employees with a forecast role will create forecast line with a positive + capacity and type "confirmed" for each day on which they work. This + information comes from their work calendar, and the different roles that are + linked to the employee. + +* draft sale orders (if enabled in the settings) will create forecast lines of + type "forecast" for each sale order line having a product with a forecast + role and start and end dates. The forecast hours are negative + +* confirmed sale orders don't create forecast lines. This is handled by the + tasks created at the confirmation of the sale order + +* project tasks create forecast lines if they have a linked role and planned start/end + dates. The type of the line will depend on the related project's stage. The + `forecast_hours` field is based on the remaining time of the task, which is spread + on the work days of the planned start and end date of the task. If the + current date is in the middle of the planned duration of the task, it is used + as the start date. If the planned end date is in the past the task does not + generate forecast lines (and you need to fix your planning). In case multiple + employees are assigned to the task the forecast is split equally between + them. + +* holiday requests create negative forecast lines with type "forecast" when + they are pending manager validation. + +* Validated holiday requests do not generate forecast lines, as they alter the + work calendar of the employee: the employee will not have a positive line + associated to his leave days. + +The creation of forecast lines is done either in real time when some actions +are performed by the user (requesting leaves, updating the remaining time on a +project task, timesheeting) and also via a cron that runs on a daily basis. The +cron is required to cleanup lines related to dates in the past and to recompute +the lines related to project tasks by computing the ratio of remaing time on +the tasks on the remaining days, for tasks which are in progress. So, to start +using consolidated forecast report you first need to set everything mentioned +in Usage section. Then, probably run Forecast recomputation cron manually from +Scheduled Actions or wait till cron creates records. diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html index 9e96d47fcd..29058b0689 100644 --- a/project_forecast_line/static/description/index.html +++ b/project_forecast_line/static/description/index.html @@ -3,7 +3,7 @@ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> -<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" /> +<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" /> <title>Project Forecast Lines</title> <style type="text/css"> @@ -368,23 +368,82 @@ <h1 class="title">Project Forecast Lines</h1> !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> <p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p> -<p>This module allows to plan your resources using forecast lines. -For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will “consume” the work time capacity of the employees. -Forecast lines also come in two states “forecast” or “confirmed”, depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type “forecast”, whereas tasks for project which are in a running state create lines with type “confirmed”.</p> +<p>This module allows to plan your resources using forecast lines.</p> +<p>For each employee of the company, the module will generate forecast line +records with a positive capacity based on their working time schedules. Then, +tasks assigned to employees will generate forecast lines with a negative +capacity which will “consume” the work time capacity of the employees.</p> +<p>The idea is that you can then see the work capacity and scheduled work of +people by summing the “forecasts” per time period. If you have more resources +(positive forecast) than work (negative forecast) you will have a positive net +sum. Otherwise you are in trouble and need to recruit or reschedule your +work. Another way to use the report is checking when the work capacity of a +department becomes positive (or high enough) in order to provide you potential +customers with an estimate of when a project would be able to start.</p> +<p>Forecast lines also come in two states “forecast” or “confirmed”, depending on +whether the consumption is confirmed or not. For instance, holidays requests +and sales quotation lines create lines of type “forecast”, whereas tasks for +project which are in a running state create lines with type “confirmed”.</p> <p><strong>Table of contents</strong></p> <div class="contents local topic" id="contents"> <ul class="simple"> -<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li> -<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul> -<li><a class="reference internal" href="#authors" id="id3">Authors</a></li> -<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li> -<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li> +<li><a class="reference internal" href="#usage" id="id1">Usage</a></li> +<li><a class="reference internal" href="#bug-tracker" id="id2">Bug Tracker</a></li> +<li><a class="reference internal" href="#credits" id="id3">Credits</a><ul> +<li><a class="reference internal" href="#authors" id="id4">Authors</a></li> +<li><a class="reference internal" href="#contributors" id="id5">Contributors</a></li> +<li><a class="reference internal" href="#maintainers" id="id6">Maintainers</a></li> </ul> </li> </ul> </div> +<div class="section" id="usage"> +<h1><a class="toc-backref" href="#id1">Usage</a></h1> +<p>Forecast lines have the following data:</p> +<ul class="simple"> +<li>Forecast hours: it is positive for resources (employees) and negative for +things which consume time</li> +<li>From and To date which are the beginning and ending of the period of the +capacity</li> +<li>Consolidated forecast: this is a computed field, which is computed as follows:<ul> +<li>for costs (project tasks for instance) we take the absolute value of the +forecast hours (so it is a positive number)</li> +<li>for resources (employee capacity for a period), we take the capacity and +substract all the costs for that employee on the same period. So it will be +positive if the employee still has some free time, and negative if he is +overloaded with work.</li> +</ul> +</li> +</ul> +<p>Objects creating forecast lines:</p> +<ul class="simple"> +<li>employees with a forecast role will create forecast line with a positive +capacity and type “confirmed” for each day on which they work. This +information comes from their work calendar, and the different roles that are +linked to the employee.</li> +<li>draft sale orders (if enabled in the settings) will create forecast lines of +type “forecast” for each sale order line having a product with a forecast +role and start and end dates. The forecast hours are negative</li> +<li>confirmed sale orders don’t create forecast lines. This is handled by the +tasks created at the confirmation of the sale order</li> +<li>project tasks create forecast lines if they have a linked role and start/end +date. The type of the line will depend on the related project’s stage. The +forecast quantity is based on the remaining time of the task, which is spread +on the work days of the planned start and end date of the task. If the +current date is in the middle of the planned duration of the task, it is used +as the start date. If the planned end date is in the past the task does not +generate forecast lines (and you need to fix your planning). In case multiple +employees are assigned to the task the forecast is split equally between +them.</li> +<li>holiday requests create negative forecast lines with type “forecast” when +they are pending manager validation.</li> +<li>Validated holiday requests do not generate forecast lines, as they alter the +work calendar of the employee: the employee will not have a positive line +associated to his leave days.</li> +</ul> +</div> <div class="section" id="bug-tracker"> -<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1> +<h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1> <p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/project/issues">GitHub Issues</a>. 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 @@ -392,22 +451,22 @@ <h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1> <p>Do not contact contributors directly about support or help with technical issues.</p> </div> <div class="section" id="credits"> -<h1><a class="toc-backref" href="#id2">Credits</a></h1> +<h1><a class="toc-backref" href="#id3">Credits</a></h1> <div class="section" id="authors"> -<h2><a class="toc-backref" href="#id3">Authors</a></h2> +<h2><a class="toc-backref" href="#id4">Authors</a></h2> <ul class="simple"> <li>Camptocamp SA</li> </ul> </div> <div class="section" id="contributors"> -<h2><a class="toc-backref" href="#id4">Contributors</a></h2> +<h2><a class="toc-backref" href="#id5">Contributors</a></h2> <ul class="simple"> <li>Alexandre Fayolle <<a class="reference external" href="mailto:alexandre.fayolle@camptocamp.com">alexandre.fayolle@camptocamp.com</a>></li> <li>Maksym Yankin <<a class="reference external" href="mailto:maksym.yankin@camptocamp.com">maksym.yankin@camptocamp.com</a>></li> </ul> </div> <div class="section" id="maintainers"> -<h2><a class="toc-backref" href="#id5">Maintainers</a></h2> +<h2><a class="toc-backref" href="#id6">Maintainers</a></h2> <p>This module is maintained by the OCA.</p> <a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a> <p>OCA, or the Odoo Community Association, is a nonprofit organization whose From db498daa97a635ed390b076e01041b285fd3430e Mon Sep 17 00:00:00 2001 From: OCA-git-bot <oca-git-bot@odoo-community.org> Date: Wed, 31 Aug 2022 11:55:35 +0000 Subject: [PATCH 05/33] [UPD] README.rst --- project_forecast_line/README.rst | 28 +++++++++++++++---- .../static/description/index.html | 28 +++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst index 87c7d75b27..72d80fd34f 100644 --- a/project_forecast_line/README.rst +++ b/project_forecast_line/README.rst @@ -45,6 +45,13 @@ whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed". +To get the best experience using the Forecast application you may want to install: + +* project_forecast_line_holidays_public module which takes public holidays into + account during forecast lines creation + +* project_forecast_line_bokeh_chart module which improves the reports of + project_forecast_line module by using the bokeh widget available in OCA/web **Table of contents** @@ -57,7 +64,7 @@ Usage Forecast lines have the following data: * Forecast hours: it is positive for resources (employees) and negative for - things which consume time + things which consume time (project tasks, for instance) * From and To date which are the beginning and ending of the period of the capacity @@ -72,6 +79,9 @@ Forecast lines have the following data: positive if the employee still has some free time, and negative if he is overloaded with work. + * this consolidated forecast is currently converted to days to ease + readability of the forecast report + Objects creating forecast lines: @@ -87,9 +97,9 @@ Objects creating forecast lines: * confirmed sale orders don't create forecast lines. This is handled by the tasks created at the confirmation of the sale order -* project tasks create forecast lines if they have a linked role and start/end - date. The type of the line will depend on the related project's stage. The - forecast quantity is based on the remaining time of the task, which is spread +* project tasks create forecast lines if they have a linked role and planned start/end + dates. The type of the line will depend on the related project's stage. The + `forecast_hours` field is based on the remaining time of the task, which is spread on the work days of the planned start and end date of the task. If the current date is in the middle of the planned duration of the task, it is used as the start date. If the planned end date is in the past the task does not @@ -104,7 +114,15 @@ Objects creating forecast lines: work calendar of the employee: the employee will not have a positive line associated to his leave days. - +The creation of forecast lines is done either in real time when some actions +are performed by the user (requesting leaves, updating the remaining time on a +project task, timesheeting) and also via a cron that runs on a daily basis. The +cron is required to cleanup lines related to dates in the past and to recompute +the lines related to project tasks by computing the ratio of remaing time on +the tasks on the remaining days, for tasks which are in progress. So, to start +using consolidated forecast report you first need to set everything mentioned +in Usage section. Then, probably run Forecast recomputation cron manually from +Scheduled Actions or wait till cron creates records. Bug Tracker =========== diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html index 29058b0689..4eefcd70df 100644 --- a/project_forecast_line/static/description/index.html +++ b/project_forecast_line/static/description/index.html @@ -3,7 +3,7 @@ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> -<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" /> +<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" /> <title>Project Forecast Lines</title> <style type="text/css"> @@ -384,6 +384,13 @@ <h1 class="title">Project Forecast Lines</h1> whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type “forecast”, whereas tasks for project which are in a running state create lines with type “confirmed”.</p> +<p>To get the best experience using the Forecast application you may want to install:</p> +<ul class="simple"> +<li>project_forecast_line_holidays_public module which takes public holidays into +account during forecast lines creation</li> +<li>project_forecast_line_bokeh_chart module which improves the reports of +project_forecast_line module by using the bokeh widget available in OCA/web</li> +</ul> <p><strong>Table of contents</strong></p> <div class="contents local topic" id="contents"> <ul class="simple"> @@ -402,7 +409,7 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> <p>Forecast lines have the following data:</p> <ul class="simple"> <li>Forecast hours: it is positive for resources (employees) and negative for -things which consume time</li> +things which consume time (project tasks, for instance)</li> <li>From and To date which are the beginning and ending of the period of the capacity</li> <li>Consolidated forecast: this is a computed field, which is computed as follows:<ul> @@ -412,6 +419,8 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> substract all the costs for that employee on the same period. So it will be positive if the employee still has some free time, and negative if he is overloaded with work.</li> +<li>this consolidated forecast is currently converted to days to ease +readability of the forecast report</li> </ul> </li> </ul> @@ -426,9 +435,9 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> role and start and end dates. The forecast hours are negative</li> <li>confirmed sale orders don’t create forecast lines. This is handled by the tasks created at the confirmation of the sale order</li> -<li>project tasks create forecast lines if they have a linked role and start/end -date. The type of the line will depend on the related project’s stage. The -forecast quantity is based on the remaining time of the task, which is spread +<li>project tasks create forecast lines if they have a linked role and planned start/end +dates. The type of the line will depend on the related project’s stage. The +<cite>forecast_hours</cite> field is based on the remaining time of the task, which is spread on the work days of the planned start and end date of the task. If the current date is in the middle of the planned duration of the task, it is used as the start date. If the planned end date is in the past the task does not @@ -441,6 +450,15 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> work calendar of the employee: the employee will not have a positive line associated to his leave days.</li> </ul> +<p>The creation of forecast lines is done either in real time when some actions +are performed by the user (requesting leaves, updating the remaining time on a +project task, timesheeting) and also via a cron that runs on a daily basis. The +cron is required to cleanup lines related to dates in the past and to recompute +the lines related to project tasks by computing the ratio of remaing time on +the tasks on the remaining days, for tasks which are in progress. So, to start +using consolidated forecast report you first need to set everything mentioned +in Usage section. Then, probably run Forecast recomputation cron manually from +Scheduled Actions or wait till cron creates records.</p> </div> <div class="section" id="bug-tracker"> <h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1> From 39abf09ad36f315ed3e137910e2b3f9d41315528 Mon Sep 17 00:00:00 2001 From: OCA-git-bot <oca-git-bot@odoo-community.org> Date: Wed, 31 Aug 2022 11:55:36 +0000 Subject: [PATCH 06/33] project_forecast_line 15.0.1.0.1 --- project_forecast_line/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py index de8349b15e..961c7616ff 100644 --- a/project_forecast_line/__manifest__.py +++ b/project_forecast_line/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Project Forecast Lines", "summary": "Project Forecast Lines", - "version": "15.0.1.0.0", + "version": "15.0.1.0.1", "author": "Camptocamp SA, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Project", From 733013ed5d91e02bf5d3950ad51aa605788e0a2e Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Wed, 31 Aug 2022 17:13:22 +0200 Subject: [PATCH 07/33] [FIX] project_forecast_line consolidated capacity in some cases, we could have capacity consumed by a task which was not matching a work capacity line in the period because of a faulty optimisation we were making which skipped the creation of lines with a capacity of 0 -> then we had no line on which to compute the negative consolidated capacity. We remove the optimisation to fix this case and show the problematic periods in the consolidated capacity graphs --- project_forecast_line/models/forecast_line.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 50d54f2da8..22e7a38a64 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -245,10 +245,11 @@ def _split_per_period( resource, calendar, ) - if period_forecast == 0: - # don"t create forecast lines with a forecast of 0 - curr_date = next_date - continue + # note we do create lines even if the period_forecast is 0, as this + # ensures that consolidated capacity can be computed: if there is + # no line for a day when the employee does not work, but for some + # reason there is a need on that day, we need the 0 capacity line + # to compute the negative consolidated capacity. period_forecast *= daily_forecast period_cost = period_forecast * unit_cost updates = { From 5cafbbfb4e16ff51f7598c80ff2b15bc0a4b66c8 Mon Sep 17 00:00:00 2001 From: OCA-git-bot <oca-git-bot@odoo-community.org> Date: Fri, 2 Sep 2022 14:53:01 +0000 Subject: [PATCH 08/33] project_forecast_line 15.0.1.0.2 --- project_forecast_line/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py index 961c7616ff..037edf189a 100644 --- a/project_forecast_line/__manifest__.py +++ b/project_forecast_line/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Project Forecast Lines", "summary": "Project Forecast Lines", - "version": "15.0.1.0.1", + "version": "15.0.1.0.2", "author": "Camptocamp SA, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Project", From 03ebd53209ea14436117746f8879d46a5d309c84 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Tue, 30 Aug 2022 11:39:39 +0300 Subject: [PATCH 09/33] [15.0][FIX] project_forecast_line: take roles into account on calc --- project_forecast_line/models/forecast_line.py | 20 ++- project_forecast_line/models/project_task.py | 5 +- .../tests/test_forecast_line.py | 152 +++++++++++++++++- 3 files changed, 165 insertions(+), 12 deletions(-) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 22e7a38a64..72026960f6 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -88,12 +88,15 @@ class ForecastLine(models.Model): @api.depends("employee_id", "date_from", "type", "res_model") def _compute_employee_forecast_line_id(self): 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 +107,23 @@ 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 + 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 7e4df82a15..0b3cb91d16 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/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index 9a1a6a9d98..34fbb1bfd3 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) From 8794f99cba0d20b7c108c75526089263ed2ce099 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Mon, 5 Sep 2022 11:03:52 +0300 Subject: [PATCH 10/33] [15.0][IMP] project_forecast_line: setting to control consumption states --- project_forecast_line/models/forecast_line.py | 10 +++++++++- project_forecast_line/models/res_company.py | 16 ++++++++++++++++ .../models/res_config_settings.py | 4 +++- .../views/res_config_settings_views.xml | 11 +++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 72026960f6..11cfc325a3 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -85,8 +85,13 @@ 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") @@ -111,7 +116,10 @@ def _compute_employee_forecast_line_id(self): (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": + 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 ) diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py index 4d88fee87d..c3a41c0661 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 4ac43649ec..4c7cc90534 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/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml index e273587afb..61b15942a2 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" /> </div> + <div class="mt8"> + <label for="forecast_consumption_states" /> + <div class="text-muted"> + Select the states for which the consumption is confirmed or not + </div> + <field + name="forecast_consumption_states" + required="1" + class="o_light_label" + /> + </div> </div> </div> </div> From cb60cd8d7a1e1ebe3fe7833442130651c2ab8dc1 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Mon, 5 Sep 2022 16:01:32 +0200 Subject: [PATCH 11/33] bump version after clicking on merge by mistake --- project_forecast_line/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py index 037edf189a..7f36b8653a 100644 --- a/project_forecast_line/__manifest__.py +++ b/project_forecast_line/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Project Forecast Lines", "summary": "Project Forecast Lines", - "version": "15.0.1.0.2", + "version": "15.0.1.0.3", "author": "Camptocamp SA, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Project", From f3e3d88762f263158e2812e4c18a9c1c910faf7c Mon Sep 17 00:00:00 2001 From: oca-ci <oca-ci@odoo-community.org> Date: Mon, 5 Sep 2022 14:05:17 +0000 Subject: [PATCH 12/33] [UPD] Update project_forecast_line.pot --- .../i18n/project_forecast_line.pot | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/project_forecast_line/i18n/project_forecast_line.pot b/project_forecast_line/i18n/project_forecast_line.pot index 8d1a46263f..ebf02c37ed 100644 --- a/project_forecast_line/i18n/project_forecast_line.pot +++ b/project_forecast_line/i18n/project_forecast_line.pot @@ -34,6 +34,11 @@ msgstr "" msgid "Company" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__confirmed +msgid "Compute consolidated forecast for lines of type confirmed" +msgstr "" + #. module: project_forecast_line #: model:ir.model,name:project_forecast_line.model_res_config_settings msgid "Config Settings" @@ -56,6 +61,12 @@ msgstr "" msgid "Consolidated Forecast" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_consumption_states +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_consumption_states +msgid "Consumption state rules" +msgstr "" + #. module: project_forecast_line #: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost msgid "Cost" @@ -178,6 +189,16 @@ msgstr "" msgid "Employee role for task matching" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_consumption_states +#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_consumption_states +msgid "" +"For instance, holidays requests and sales quotation linescreate lines of " +"type forecast and won't be taken into accountduring consolidated forecast " +"computation, whereas tasks for projectwhich are in a running state create " +"lines with type confirmedand will be used to compute consolidated forecast." +msgstr "" + #. module: project_forecast_line #: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines #: model:ir.model,name:project_forecast_line.model_forecast_line @@ -279,6 +300,11 @@ msgstr "" msgid "ID" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__forecast_confirmed +msgid "Include lines of type forecast in consolidated forecast computation" +msgstr "" + #. module: project_forecast_line #: model:ir.model,name:project_forecast_line.model_hr_job msgid "Job Position" @@ -423,6 +449,11 @@ msgstr "" msgid "Sales Order Line" msgstr "" +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Select the states for which the consumption is confirmed or not" +msgstr "" + #. module: project_forecast_line #: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence msgid "Sequence" From 4ca2225c2254db7a1e113b38f933add31b81f594 Mon Sep 17 00:00:00 2001 From: OCA Transbot <transbot@odoo-community.org> Date: Mon, 5 Sep 2022 14:05:32 +0000 Subject: [PATCH 13/33] Update translation files Updated by "Update PO files to match POT (msgmerge)" hook in Weblate. Translation: project-15.0/project-15.0-project_forecast_line Translate-URL: https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line/ --- project_forecast_line/i18n/fr.po | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/project_forecast_line/i18n/fr.po b/project_forecast_line/i18n/fr.po index 3da256fe38..2d563eb6ac 100644 --- a/project_forecast_line/i18n/fr.po +++ b/project_forecast_line/i18n/fr.po @@ -37,6 +37,11 @@ msgstr "" msgid "Company" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__confirmed +msgid "Compute consolidated forecast for lines of type confirmed" +msgstr "" + #. module: project_forecast_line #: model:ir.model,name:project_forecast_line.model_res_config_settings msgid "Config Settings" @@ -59,6 +64,12 @@ msgstr "" msgid "Consolidated Forecast" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_consumption_states +#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_consumption_states +msgid "Consumption state rules" +msgstr "" + #. module: project_forecast_line #: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost msgid "Cost" @@ -181,6 +192,16 @@ msgstr "" msgid "Employee role for task matching" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_consumption_states +#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_consumption_states +msgid "" +"For instance, holidays requests and sales quotation linescreate lines of " +"type forecast and won't be taken into accountduring consolidated forecast " +"computation, whereas tasks for projectwhich are in a running state create " +"lines with type confirmedand will be used to compute consolidated forecast." +msgstr "" + #. module: project_forecast_line #: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines #: model:ir.model,name:project_forecast_line.model_forecast_line @@ -282,6 +303,11 @@ msgstr "" msgid "ID" msgstr "" +#. module: project_forecast_line +#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__forecast_confirmed +msgid "Include lines of type forecast in consolidated forecast computation" +msgstr "" + #. module: project_forecast_line #: model:ir.model,name:project_forecast_line.model_hr_job msgid "Job Position" @@ -429,6 +455,11 @@ msgstr "Bon de commande" msgid "Sales Order Line" msgstr "Bon de commande" +#. module: project_forecast_line +#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form +msgid "Select the states for which the consumption is confirmed or not" +msgstr "" + #. module: project_forecast_line #: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence msgid "Sequence" From fde40b73d8cf25f82d1d3e430aa29b3f250848f2 Mon Sep 17 00:00:00 2001 From: ntsirintanis <ntsirintanis@therp.nl> Date: Wed, 7 Sep 2022 09:50:49 +0200 Subject: [PATCH 14/33] [14.0][BACKPORT][WIP]project_forecast_line from 15.0 to 14.0 --- project_forecast_line/__manifest__.py | 4 +- project_forecast_line/data/project_data.xml | 19 ---------- project_forecast_line/models/__init__.py | 2 - project_forecast_line/models/hr_employee.py | 5 +-- project_forecast_line/models/hr_leave.py | 2 +- .../models/project_project.py | 18 --------- .../models/project_project_stage.py | 21 ---------- project_forecast_line/models/project_task.py | 27 ++++++------- .../tests/test_forecast_line.py | 38 +++++++++---------- .../odoo/addons/project_forecast_line | 1 + setup/project_forecast_line/setup.py | 6 +++ 11 files changed, 38 insertions(+), 105 deletions(-) delete mode 100644 project_forecast_line/data/project_data.xml delete mode 100644 project_forecast_line/models/project_project.py delete mode 100644 project_forecast_line/models/project_project_stage.py create mode 120000 setup/project_forecast_line/odoo/addons/project_forecast_line create mode 100644 setup/project_forecast_line/setup.py diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py index 7f36b8653a..91b4ceb4b7 100644 --- a/project_forecast_line/__manifest__.py +++ b/project_forecast_line/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Project Forecast Lines", "summary": "Project Forecast Lines", - "version": "15.0.1.0.3", + "version": "14.0.1.0.3", "author": "Camptocamp SA, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Project", @@ -18,10 +18,8 @@ "views/forecast_role_views.xml", "views/product_views.xml", "views/project_task_views.xml", - "views/project_project_stage_views.xml", "views/res_config_settings_views.xml", "data/ir_cron.xml", - "data/project_data.xml", ], "installable": True, "application": True, diff --git a/project_forecast_line/data/project_data.xml b/project_forecast_line/data/project_data.xml deleted file mode 100644 index 1dd857c7af..0000000000 --- a/project_forecast_line/data/project_data.xml +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="utf-8" ?> -<odoo> - <!-- Project Stages --> - <record id="project.project_project_stage_0" model="project.project.stage"> - <field name="forecast_line_type">forecast</field> - </record> - - <record id="project.project_project_stage_1" model="project.project.stage"> - <field name="forecast_line_type">confirmed</field> - </record> - - <record id="project.project_project_stage_2" model="project.project.stage"> - <field name="forecast_line_type" /> - </record> - - <record id="project.project_project_stage_3" model="project.project.stage"> - <field name="forecast_line_type" /> - </record> -</odoo> diff --git a/project_forecast_line/models/__init__.py b/project_forecast_line/models/__init__.py index 9131303b85..972739fd50 100644 --- a/project_forecast_line/models/__init__.py +++ b/project_forecast_line/models/__init__.py @@ -10,5 +10,3 @@ from . import account_analytic_line from . import res_config_settings from . import resource_calendar_leaves -from . import project_project_stage -from . import project_project diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py index 2c854eff82..60715aebd3 100644 --- a/project_forecast_line/models/hr_employee.py +++ b/project_forecast_line/models/hr_employee.py @@ -46,10 +46,7 @@ def _check_job_role(self, values): job = self.env["hr.job"].browse(new_job_id) if job.role_id and "role_ids" not in values: values = values.copy() - values["role_ids"] = [ - fields.Command.clear(), - fields.Command.create({"role_id": job.role_id.id}), - ] + values["role_ids"] = [(6, 0, job.role_id.ids)] return values diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py index 7c7c7c18d1..88f59c303c 100644 --- a/project_forecast_line/models/hr_leave.py +++ b/project_forecast_line/models/hr_leave.py @@ -69,7 +69,7 @@ def _recompute_forecast_lines(self, force_company_id=None): to_update = self.with_company(company).search( [ ("date_to", ">=", today), - ("employee_company_id", "=", company.id), + ("employee_id.company_id", "=", company.id), ] ) to_update._update_forecast_lines() diff --git a/project_forecast_line/models/project_project.py b/project_forecast_line/models/project_project.py deleted file mode 100644 index 0daf904664..0000000000 --- a/project_forecast_line/models/project_project.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright 2022 Camptocamp SA -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import models - - -class ProjectProject(models.Model): - _inherit = "project.project" - - def _update_forecast_lines_trigger_fields(self): - return ["stage_id"] - - def write(self, values): - res = super().write(values) - written_fields = list(values.keys()) - trigger_fields = self._update_forecast_lines_trigger_fields() - if any(field in written_fields for field in trigger_fields): - self.task_ids._update_forecast_lines() - return res diff --git a/project_forecast_line/models/project_project_stage.py b/project_forecast_line/models/project_project_stage.py deleted file mode 100644 index 02eb6d63dc..0000000000 --- a/project_forecast_line/models/project_project_stage.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2022 Camptocamp SA -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import fields, models - - -class ProjectProjectStage(models.Model): - _inherit = "project.project.stage" - - forecast_line_type = fields.Selection( - [("forecast", "Forecast"), ("confirmed", "Confirmed")], - help="type of forecast lines created by the tasks of projects in that stage", - ) - - def write(self, values): - res = super().write(values) - if "forecast_line_type" in values: - projects = self.env["project.project"].search( - [("stage_id", "in", self.ids)] - ) - projects.mapped("task_ids")._update_forecast_lines() - return res diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py index 0b3cb91d16..b868b4ef31 100644 --- a/project_forecast_line/models/project_task.py +++ b/project_forecast_line/models/project_task.py @@ -35,7 +35,7 @@ def _update_forecast_lines_trigger_fields(self): "remaining_hours", "name", "planned_time", - "user_ids", + "user_id", ] def write(self, values): @@ -51,18 +51,17 @@ def write(self, values): self._update_forecast_lines() return res - @api.onchange("user_ids") - def onchange_user_ids(self): + @api.onchange("user_id") + def onchange_user_id(self): for task in self: - if not task.user_ids: + if not task.user_id: continue if task.forecast_role_id: continue - employees = task.mapped("user_ids.employee_id") - for employee in employees: - if employee.main_role_id: - task.forecast_role_id = employee.main_role_id - break + employee = task.user_id.employee_id + if employee.main_role_id: + task.forecast_role_id = employee.main_role_id + break def _update_forecast_lines(self): today = fields.Date.context_today(self) @@ -73,15 +72,11 @@ def _update_forecast_lines(self): [("res_id", "in", self.ids), ("res_model", "=", self._name)] ).unlink() for task in self: + forecast_type = "forecast" if not task.forecast_role_id: _logger.info("skip task %s: no forecast role", task) continue - elif task.project_id.stage_id: - forecast_type = task.project_id.stage_id.forecast_line_type - if not forecast_type: - _logger.info("skip task %s: no forecast for project state", task) - continue # closed / cancelled stage - elif task.sale_line_id: + if task.sale_line_id: sale_state = task.sale_line_id.state if sale_state == "cancel": _logger.info("skip task %s: cancelled sale", task) @@ -109,7 +104,7 @@ def _update_forecast_lines(self): continue date_start = max(today, task.forecast_date_planned_start) date_end = max(today, task.forecast_date_planned_end) - employee_ids = task.mapped("user_ids.employee_id").ids + employee_ids = task.mapped("user_id.employee_id").ids if not employee_ids: employee_ids = [False] _logger.debug( diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index 34fbb1bfd3..ad63c1edd2 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -4,10 +4,10 @@ from freezegun import freeze_time -from odoo.tests.common import Form, TransactionCase +from odoo.tests.common import Form, SavepointCase -class BaseForecastLineTest(TransactionCase): +class BaseForecastLineTest(SavepointCase): @classmethod @freeze_time("2022-01-01") def setUpClass(cls): @@ -57,7 +57,7 @@ def setUpClass(cls): cls.product_dev_tm = cls.env["product.product"].create( { "name": "development time and material", - "detailed_type": "service", + "type": "service", "service_tracking": "task_in_project", "price": 95, "standard_price": 75, @@ -69,7 +69,7 @@ def setUpClass(cls): cls.product_consultant_tm = cls.env["product.product"].create( { "name": "consultant time and material", - "detailed_type": "service", + "type": "service", "service_tracking": "task_in_project", "price": 100, "standard_price": 80, @@ -82,7 +82,7 @@ def setUpClass(cls): cls.product_pm_tm = cls.env["product.product"].create( { "name": "pm time and material", - "detailed_type": "service", + "type": "service", "service_tracking": "task_in_project", "price": 120, "standard_price": 100, @@ -499,8 +499,6 @@ def test_task_forecast_lines_consolidated_forecast(self): ) self.assertEqual(len(employee_forecast), 1) project = self.env["project.project"].create({"name": "TestProject"}) - # 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": "Task1", @@ -512,8 +510,9 @@ def test_task_forecast_lines_consolidated_forecast(self): } ) task.remaining_hours = 6 - task.user_ids = self.user_consultant + task.user_id = self.user_consultant forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) + forecast.type = "confirmed" self.assertEqual(len(forecast), 1) # using assertEqual on purpose here self.assertEqual(forecast.forecast_hours, -6.0) @@ -533,8 +532,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self): ) self.assertEqual(len(employee_forecast), 1) project = self.env["project.project"].create({"name": "TestProject"}) - # 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": "Task1", @@ -546,8 +543,9 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self): } ) task.remaining_hours = 10 - task.user_ids = self.user_consultant + task.user_id = self.user_consultant forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) + forecast.type = "confirmed" self.assertEqual(len(forecast), 1) # using assertEqual on purpose here self.assertEqual(forecast.forecast_hours, -10.0) @@ -569,8 +567,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks ) self.assertEqual(len(employee_forecast), 1) project = self.env["project.project"].create({"name": "TestProject"}) - # set project in stage "in progress" to get confirmed forecast - project.stage_id = self.env.ref("project.project_project_stage_1") task1 = self.env["project.task"].create( { "name": "Task1", @@ -582,8 +578,9 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks } ) task1.remaining_hours = 10 - task1.user_ids = self.user_consultant + task1.user_id = self.user_consultant forecast1 = self.env["forecast.line"].search([("task_id", "=", task1.id)]) + forecast1.type = "confirmed" self.assertEqual(len(forecast1), 1) task2 = self.env["project.task"].create( { @@ -596,8 +593,9 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks } ) task2.remaining_hours = 4 - task2.user_ids = self.user_consultant + task2.user_id = self.user_consultant forecast2 = self.env["forecast.line"].search([("task_id", "=", task2.id)]) + forecast2.type = "confirmed" # using assertEqual on purpose here self.assertEqual( forecast1.employee_resource_forecast_line_id, @@ -643,8 +641,6 @@ def test_task_forecast_lines_employee_different_roles(self): ) 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", @@ -655,7 +651,7 @@ def test_task_forecast_lines_employee_different_roles(self): "planned_hours": 8, } ) - task.user_ids = self.user_consultant + task.user_id = 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 @@ -669,6 +665,7 @@ def test_task_forecast_lines_employee_different_roles(self): lambda l: l.res_model == "hr.employee.forecast.role" and l.forecast_role_id == self.role_consultant )[0] + employee_forecast.type = "confirmed" self.assertEqual(forecast_consultant.forecast_hours, 6.0) self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25) forecast_pm = employee_forecast.filtered( @@ -714,8 +711,6 @@ def test_task_forecast_lines_employee_main_role(self): ) 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", @@ -726,7 +721,7 @@ def test_task_forecast_lines_employee_main_role(self): "planned_hours": 8, } ) - task.user_ids = self.user_consultant + task.user_id = 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 @@ -735,6 +730,7 @@ def test_task_forecast_lines_employee_main_role(self): employee_forecast = self.env["forecast.line"].search( [("employee_id", "=", self.employee_consultant.id)] ) + employee_forecast.type = "confirmed" # 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" diff --git a/setup/project_forecast_line/odoo/addons/project_forecast_line b/setup/project_forecast_line/odoo/addons/project_forecast_line new file mode 120000 index 0000000000..c46f8df746 --- /dev/null +++ b/setup/project_forecast_line/odoo/addons/project_forecast_line @@ -0,0 +1 @@ +../../../../project_forecast_line \ No newline at end of file diff --git a/setup/project_forecast_line/setup.py b/setup/project_forecast_line/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/project_forecast_line/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 8cafc370dfa19c7bcdbe204eee49d45b5b3734bd Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Wed, 7 Sep 2022 10:02:16 +0300 Subject: [PATCH 15/33] [15.0][FIX] project_forecast_line: add missing access rights --- project_forecast_line/__manifest__.py | 1 + project_forecast_line/models/forecast_line.py | 9 +++++++-- project_forecast_line/models/hr_employee.py | 6 ++++-- project_forecast_line/models/product_template.py | 2 +- project_forecast_line/models/project_task.py | 2 +- project_forecast_line/security/ir.model.access.csv | 4 ++-- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py index 91b4ceb4b7..6ee8255ff2 100644 --- a/project_forecast_line/__manifest__.py +++ b/project_forecast_line/__manifest__.py @@ -22,5 +22,6 @@ "data/ir_cron.xml", ], "installable": True, + "development_status": "Alpha", "application": True, } diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 11cfc325a3..ea2c08da13 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -27,11 +27,16 @@ class ForecastLine(models.Model): ) date_to = fields.Date(required=True) forecast_role_id = fields.Many2one( - "forecast.role", string="Forecast role", required=True, index=True + "forecast.role", + string="Forecast role", + required=True, + index=True, + ondelete="restrict", ) employee_id = fields.Many2one("hr.employee", string="Employee") employee_forecast_role_id = fields.Many2one( - "hr.employee.forecast.role", string="Employee Forecast Role" + "hr.employee.forecast.role", + string="Employee Forecast Role", ) project_id = fields.Many2one("project.project", index=True, string="Project") task_id = fields.Many2one("project.task", index=True, string="Task") diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py index 60715aebd3..9afb519899 100644 --- a/project_forecast_line/models/hr_employee.py +++ b/project_forecast_line/models/hr_employee.py @@ -9,14 +9,16 @@ class HrJob(models.Model): _inherit = "hr.job" - role_id = fields.Many2one("forecast.role") + role_id = fields.Many2one("forecast.role", ondelete="restrict") class HrEmployee(models.Model): _inherit = "hr.employee" role_ids = fields.One2many("hr.employee.forecast.role", "employee_id") - main_role_id = fields.Many2one("forecast.role", compute="_compute_main_role_id") + main_role_id = fields.Many2one( + "forecast.role", compute="_compute_main_role_id", ondelete="restrict" + ) def _compute_main_role_id(self): # can"t store as it depends on current date diff --git a/project_forecast_line/models/product_template.py b/project_forecast_line/models/product_template.py index 6fd7faa8d8..fb9874266a 100644 --- a/project_forecast_line/models/product_template.py +++ b/project_forecast_line/models/product_template.py @@ -6,4 +6,4 @@ class ProductTemplate(models.Model): _inherit = "product.template" - forecast_role_id = fields.Many2one("forecast.role") + forecast_role_id = fields.Many2one("forecast.role", ondelete="restrict") diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py index b868b4ef31..da81740962 100644 --- a/project_forecast_line/models/project_task.py +++ b/project_forecast_line/models/project_task.py @@ -10,7 +10,7 @@ class ProjectTask(models.Model): _inherit = "project.task" - forecast_role_id = fields.Many2one("forecast.role") + forecast_role_id = fields.Many2one("forecast.role", ondelete="restrict") forecast_date_planned_start = fields.Date("Planned start date") forecast_date_planned_end = fields.Date("Planned end date") diff --git a/project_forecast_line/security/ir.model.access.csv b/project_forecast_line/security/ir.model.access.csv index 9001101172..d6690ba17f 100644 --- a/project_forecast_line/security/ir.model.access.csv +++ b/project_forecast_line/security/ir.model.access.csv @@ -1,6 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink project_forecast_line.access_forecast_line,access_forecast_line,project_forecast_line.model_forecast_line,base.group_user,1,0,0,0 project_forecast_line.access_forecast_role,access_forecast_role,project_forecast_line.model_forecast_role,base.group_user,1,0,0,0 -project_forecast_line.access_forecast_role_hr_manager,access_forecast_role_hr_manager,project_forecast_line.model_forecast_role,hr.group_hr_user,1,1,1,0 +project_forecast_line.access_forecast_role_hr_user,access_forecast_role_hr_manager,project_forecast_line.model_forecast_role,hr.group_hr_user,1,1,1,1 project_forecast_line.access_hr_employee_forecast_role,access_hr_employee_forecast_role,project_forecast_line.model_hr_employee_forecast_role,base.group_user,1,0,0,0 -project_forecast_line.access_hr_manager_forecast_role,access_hr_manager_forecast_role,project_forecast_line.model_hr_employee_forecast_role,hr.group_hr_user,1,1,1,0 +project_forecast_line.access_hr_employee_forecast_role_hr_user,access_hr_employee_forecast_role_hr_user,project_forecast_line.model_hr_employee_forecast_role,hr.group_hr_user,1,1,1,1 From 9a8eab7dafff6b3e341610273fbd351b098df790 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Wed, 17 Aug 2022 14:47:32 +0200 Subject: [PATCH 16/33] project_forecast_line: improve documentation --- project_forecast_line/README.rst | 28 ++++--------------- .../static/description/index.html | 28 ++++--------------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst index 72d80fd34f..87c7d75b27 100644 --- a/project_forecast_line/README.rst +++ b/project_forecast_line/README.rst @@ -45,13 +45,6 @@ whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed". -To get the best experience using the Forecast application you may want to install: - -* project_forecast_line_holidays_public module which takes public holidays into - account during forecast lines creation - -* project_forecast_line_bokeh_chart module which improves the reports of - project_forecast_line module by using the bokeh widget available in OCA/web **Table of contents** @@ -64,7 +57,7 @@ Usage Forecast lines have the following data: * Forecast hours: it is positive for resources (employees) and negative for - things which consume time (project tasks, for instance) + things which consume time * From and To date which are the beginning and ending of the period of the capacity @@ -79,9 +72,6 @@ Forecast lines have the following data: positive if the employee still has some free time, and negative if he is overloaded with work. - * this consolidated forecast is currently converted to days to ease - readability of the forecast report - Objects creating forecast lines: @@ -97,9 +87,9 @@ Objects creating forecast lines: * confirmed sale orders don't create forecast lines. This is handled by the tasks created at the confirmation of the sale order -* project tasks create forecast lines if they have a linked role and planned start/end - dates. The type of the line will depend on the related project's stage. The - `forecast_hours` field is based on the remaining time of the task, which is spread +* project tasks create forecast lines if they have a linked role and start/end + date. The type of the line will depend on the related project's stage. The + forecast quantity is based on the remaining time of the task, which is spread on the work days of the planned start and end date of the task. If the current date is in the middle of the planned duration of the task, it is used as the start date. If the planned end date is in the past the task does not @@ -114,15 +104,7 @@ Objects creating forecast lines: work calendar of the employee: the employee will not have a positive line associated to his leave days. -The creation of forecast lines is done either in real time when some actions -are performed by the user (requesting leaves, updating the remaining time on a -project task, timesheeting) and also via a cron that runs on a daily basis. The -cron is required to cleanup lines related to dates in the past and to recompute -the lines related to project tasks by computing the ratio of remaing time on -the tasks on the remaining days, for tasks which are in progress. So, to start -using consolidated forecast report you first need to set everything mentioned -in Usage section. Then, probably run Forecast recomputation cron manually from -Scheduled Actions or wait till cron creates records. + Bug Tracker =========== diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html index 4eefcd70df..29058b0689 100644 --- a/project_forecast_line/static/description/index.html +++ b/project_forecast_line/static/description/index.html @@ -3,7 +3,7 @@ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> -<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" /> +<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" /> <title>Project Forecast Lines</title> <style type="text/css"> @@ -384,13 +384,6 @@ <h1 class="title">Project Forecast Lines</h1> whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type “forecast”, whereas tasks for project which are in a running state create lines with type “confirmed”.</p> -<p>To get the best experience using the Forecast application you may want to install:</p> -<ul class="simple"> -<li>project_forecast_line_holidays_public module which takes public holidays into -account during forecast lines creation</li> -<li>project_forecast_line_bokeh_chart module which improves the reports of -project_forecast_line module by using the bokeh widget available in OCA/web</li> -</ul> <p><strong>Table of contents</strong></p> <div class="contents local topic" id="contents"> <ul class="simple"> @@ -409,7 +402,7 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> <p>Forecast lines have the following data:</p> <ul class="simple"> <li>Forecast hours: it is positive for resources (employees) and negative for -things which consume time (project tasks, for instance)</li> +things which consume time</li> <li>From and To date which are the beginning and ending of the period of the capacity</li> <li>Consolidated forecast: this is a computed field, which is computed as follows:<ul> @@ -419,8 +412,6 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> substract all the costs for that employee on the same period. So it will be positive if the employee still has some free time, and negative if he is overloaded with work.</li> -<li>this consolidated forecast is currently converted to days to ease -readability of the forecast report</li> </ul> </li> </ul> @@ -435,9 +426,9 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> role and start and end dates. The forecast hours are negative</li> <li>confirmed sale orders don’t create forecast lines. This is handled by the tasks created at the confirmation of the sale order</li> -<li>project tasks create forecast lines if they have a linked role and planned start/end -dates. The type of the line will depend on the related project’s stage. The -<cite>forecast_hours</cite> field is based on the remaining time of the task, which is spread +<li>project tasks create forecast lines if they have a linked role and start/end +date. The type of the line will depend on the related project’s stage. The +forecast quantity is based on the remaining time of the task, which is spread on the work days of the planned start and end date of the task. If the current date is in the middle of the planned duration of the task, it is used as the start date. If the planned end date is in the past the task does not @@ -450,15 +441,6 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1> work calendar of the employee: the employee will not have a positive line associated to his leave days.</li> </ul> -<p>The creation of forecast lines is done either in real time when some actions -are performed by the user (requesting leaves, updating the remaining time on a -project task, timesheeting) and also via a cron that runs on a daily basis. The -cron is required to cleanup lines related to dates in the past and to recompute -the lines related to project tasks by computing the ratio of remaing time on -the tasks on the remaining days, for tasks which are in progress. So, to start -using consolidated forecast report you first need to set everything mentioned -in Usage section. Then, probably run Forecast recomputation cron manually from -Scheduled Actions or wait till cron creates records.</p> </div> <div class="section" id="bug-tracker"> <h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1> From 1926c7b21fe92a3826f51591cbc339af0258f80a Mon Sep 17 00:00:00 2001 From: ntsirintanis <ntsirintanis@therp.nl> Date: Mon, 12 Sep 2022 16:41:09 +0200 Subject: [PATCH 17/33] [14.0][IMP] project_forecast_line: replace project.project.stage with project.status --- project_forecast_line/__manifest__.py | 4 ++- project_forecast_line/data/project_status.xml | 12 +++++++ project_forecast_line/models/__init__.py | 2 ++ .../models/project_project.py | 18 ++++++++++ .../models/project_status.py | 21 ++++++++++++ project_forecast_line/models/project_task.py | 9 +++-- .../tests/test_forecast_line.py | 25 +++++++++++--- .../views/project_project_stage_views.xml | 34 ------------------- .../views/project_status_views.xml | 13 +++++++ 9 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 project_forecast_line/data/project_status.xml create mode 100644 project_forecast_line/models/project_project.py create mode 100644 project_forecast_line/models/project_status.py delete mode 100644 project_forecast_line/views/project_project_stage_views.xml create mode 100644 project_forecast_line/views/project_status_views.xml diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py index 6ee8255ff2..b9d137e50b 100644 --- a/project_forecast_line/__manifest__.py +++ b/project_forecast_line/__manifest__.py @@ -8,7 +8,7 @@ "license": "AGPL-3", "category": "Project", "website": "https://github.com/OCA/project", - "depends": ["sale_timesheet", "sale_project", "hr_holidays"], + "depends": ["sale_timesheet", "sale_project", "hr_holidays", "project_status"], "data": [ "security/forecast_line_security.xml", "security/ir.model.access.csv", @@ -18,8 +18,10 @@ "views/forecast_role_views.xml", "views/product_views.xml", "views/project_task_views.xml", + "views/project_status_views.xml", "views/res_config_settings_views.xml", "data/ir_cron.xml", + "data/project_status.xml", ], "installable": True, "development_status": "Alpha", diff --git a/project_forecast_line/data/project_status.xml b/project_forecast_line/data/project_status.xml new file mode 100644 index 0000000000..5aa06e983c --- /dev/null +++ b/project_forecast_line/data/project_status.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <!-- Project Status --> + <record id="project_status.project_status_pending" model="project.status"> + <field name="forecast_line_type">forecast</field> + </record> + + <record id="project_status.project_status_in_progress" model="project.status"> + <field name="forecast_line_type">confirmed</field> + </record> + +</odoo> diff --git a/project_forecast_line/models/__init__.py b/project_forecast_line/models/__init__.py index 972739fd50..da3eba8fb0 100644 --- a/project_forecast_line/models/__init__.py +++ b/project_forecast_line/models/__init__.py @@ -10,3 +10,5 @@ from . import account_analytic_line from . import res_config_settings from . import resource_calendar_leaves +from . import project_project +from . import project_status diff --git a/project_forecast_line/models/project_project.py b/project_forecast_line/models/project_project.py new file mode 100644 index 0000000000..aeb43d4613 --- /dev/null +++ b/project_forecast_line/models/project_project.py @@ -0,0 +1,18 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + def _update_forecast_lines_trigger_fields(self): + return ["project_status"] + + def write(self, values): + res = super().write(values) + written_fields = list(values.keys()) + trigger_fields = self._update_forecast_lines_trigger_fields() + if any(field in written_fields for field in trigger_fields): + self.task_ids._update_forecast_lines() + return res diff --git a/project_forecast_line/models/project_status.py b/project_forecast_line/models/project_status.py new file mode 100644 index 0000000000..5faca41f19 --- /dev/null +++ b/project_forecast_line/models/project_status.py @@ -0,0 +1,21 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ProjectStatus(models.Model): + _inherit = "project.status" + + forecast_line_type = fields.Selection( + [("forecast", "Forecast"), ("confirmed", "Confirmed")], + help="type of forecast lines created by the tasks of projects in that status", + ) + + def write(self, values): + res = super().write(values) + if "forecast_line_type" in values: + projects = self.env["project.project"].search( + [("project_status", "in", self.ids)] + ) + projects.mapped("task_ids")._update_forecast_lines() + return res diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py index da81740962..d6ff3535f6 100644 --- a/project_forecast_line/models/project_task.py +++ b/project_forecast_line/models/project_task.py @@ -72,14 +72,19 @@ def _update_forecast_lines(self): [("res_id", "in", self.ids), ("res_model", "=", self._name)] ).unlink() for task in self: - forecast_type = "forecast" if not task.forecast_role_id: _logger.info("skip task %s: no forecast role", task) continue - if task.sale_line_id: + elif task.project_id.project_status: + forecast_type = task.project_id.project_status.forecast_line_type + if not forecast_type: + _logger.info("skip task %s: no forecast for project state", task) + continue # closed / cancelled stage + elif task.sale_line_id: sale_state = task.sale_line_id.state if sale_state == "cancel": _logger.info("skip task %s: cancelled sale", task) + continue elif sale_state == "sale": forecast_type = "confirmed" else: diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index ad63c1edd2..499efd8f54 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -499,6 +499,10 @@ def test_task_forecast_lines_consolidated_forecast(self): ) self.assertEqual(len(employee_forecast), 1) project = self.env["project.project"].create({"name": "TestProject"}) + # set project in stage "in progress" to get confirmed forecast + project.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) task = self.env["project.task"].create( { "name": "Task1", @@ -512,7 +516,6 @@ def test_task_forecast_lines_consolidated_forecast(self): task.remaining_hours = 6 task.user_id = self.user_consultant forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) - forecast.type = "confirmed" self.assertEqual(len(forecast), 1) # using assertEqual on purpose here self.assertEqual(forecast.forecast_hours, -6.0) @@ -532,6 +535,10 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self): ) self.assertEqual(len(employee_forecast), 1) project = self.env["project.project"].create({"name": "TestProject"}) + # set project in stage "in progress" to get confirmed forecast + project.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) task = self.env["project.task"].create( { "name": "Task1", @@ -545,7 +552,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self): task.remaining_hours = 10 task.user_id = self.user_consultant forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) - forecast.type = "confirmed" self.assertEqual(len(forecast), 1) # using assertEqual on purpose here self.assertEqual(forecast.forecast_hours, -10.0) @@ -567,6 +573,10 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks ) self.assertEqual(len(employee_forecast), 1) project = self.env["project.project"].create({"name": "TestProject"}) + # set project in stage "in progress" to get confirmed forecast + project.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) task1 = self.env["project.task"].create( { "name": "Task1", @@ -580,7 +590,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks task1.remaining_hours = 10 task1.user_id = self.user_consultant forecast1 = self.env["forecast.line"].search([("task_id", "=", task1.id)]) - forecast1.type = "confirmed" self.assertEqual(len(forecast1), 1) task2 = self.env["project.task"].create( { @@ -641,6 +650,10 @@ def test_task_forecast_lines_employee_different_roles(self): ) consultant_role.rate = 75 project = self.env["project.project"].create({"name": "TestProjectDiffRoles"}) + # set project in stage "in progress" to get confirmed forecast + project.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) task = self.env["project.task"].create( { "name": "TaskDiffRoles", @@ -665,7 +678,6 @@ def test_task_forecast_lines_employee_different_roles(self): lambda l: l.res_model == "hr.employee.forecast.role" and l.forecast_role_id == self.role_consultant )[0] - employee_forecast.type = "confirmed" self.assertEqual(forecast_consultant.forecast_hours, 6.0) self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25) forecast_pm = employee_forecast.filtered( @@ -711,6 +723,10 @@ def test_task_forecast_lines_employee_main_role(self): ) consultant_role.rate = 75 project = self.env["project.project"].create({"name": "TestProjectDiffRoles"}) + # set project in stage "in progress" to get confirmed forecast + project.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) task = self.env["project.task"].create( { "name": "TaskDiffRoles", @@ -730,7 +746,6 @@ def test_task_forecast_lines_employee_main_role(self): employee_forecast = self.env["forecast.line"].search( [("employee_id", "=", self.employee_consultant.id)] ) - employee_forecast.type = "confirmed" # 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" diff --git a/project_forecast_line/views/project_project_stage_views.xml b/project_forecast_line/views/project_project_stage_views.xml deleted file mode 100644 index c93ccc0b17..0000000000 --- a/project_forecast_line/views/project_project_stage_views.xml +++ /dev/null @@ -1,34 +0,0 @@ -<?xml version="1.0" encoding="utf-8" ?> -<odoo> - <record id="project_project_stage_view_tree" model="ir.ui.view"> - <field name="model">project.project.stage</field> - <field name="inherit_id" ref="project.project_project_stage_view_tree" /> - <field name="arch" type="xml"> - <field name="name" position="after"> - <field name="forecast_line_type" /> - </field> - </field> - </record> - <record id="project_project_stage_view_form_quick_create" model="ir.ui.view"> - <field name="model">project.project.stage</field> - <field - name="inherit_id" - ref="project.project_project_stage_view_form_quick_create" - /> - <field name="arch" type="xml"> - <field name="name" position="after"> - <field name="forecast_line_type" /> - </field> - </field> - </record> - <record id="project_project_stage_view_form" model="ir.ui.view"> - <field name="inherit_id" ref="project.project_project_stage_view_form" /> - <field name="model">project.project.stage</field> - <field name="arch" type="xml"> - <field name="active" position="after"> - <field name="forecast_line_type" /> - </field> - </field> - </record> - -</odoo> diff --git a/project_forecast_line/views/project_status_views.xml b/project_forecast_line/views/project_status_views.xml new file mode 100644 index 0000000000..84c0d0bbd5 --- /dev/null +++ b/project_forecast_line/views/project_status_views.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <record id="project_status_view_list" model="ir.ui.view"> + <field name="model">project.status</field> + <field name="inherit_id" ref="project_status.project_status_view_list" /> + <field name="arch" type="xml"> + <field name="name" position="after"> + <field name="forecast_line_type" /> + </field> + </field> + </record> + +</odoo> From f30cf989bac4554934928c0353f6bfbcfd3bc7f0 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Tue, 13 Sep 2022 14:03:54 +0200 Subject: [PATCH 18/33] [FIX] project_forecast_line: error in consolidated capacity During some operations, forecast lines are deleted and recreated. This can lead to some hr.employee.forecast.role lines being deleted which leaves the forecast lines on the same period without a related document to store the consolidated capacity. Normally that line is recreated shortly afterwards but the creation does not recompute the link between the parent-less lines and the new one. This patches forces the recomputation when new lines are created. --- project_forecast_line/models/forecast_line.py | 23 +++++++++++++ project_forecast_line/models/hr_leave.py | 1 + .../tests/test_forecast_line.py | 32 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index ea2c08da13..bd72233842 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -348,3 +348,26 @@ def unlink(self): # we routinely unlink forecast lines, let"s not fill the logs with this with mute_logger("odoo.models.unlink"): return super().unlink() + + @api.model_create_multi + @api.returns("self", lambda value: value.id) + def create(self, vals_list): + records = super().create(vals_list) + employee_role_lines = records.filtered( + lambda r: r.res_model == "hr.employee.forecast.role" + ) + if employee_role_lines: + # check for existing records which could have the new lines as + # employee_resource_forecast_line_id + other_lines = self.search( + [ + ("employee_resource_forecast_line_id", "=", False), + ( + "employee_id", + "in", + employee_role_lines.mapped("employee_id").ids, + ), + ] + ) + other_lines._compute_employee_forecast_line_id() + return records diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py index 88f59c303c..e74a7699af 100644 --- a/project_forecast_line/models/hr_leave.py +++ b/project_forecast_line/models/hr_leave.py @@ -53,6 +53,7 @@ def _update_forecast_lines(self): unit_cost=leave.employee_id.timesheet_cost, forecast_role_id=leave.employee_id.main_role_id.id, hr_leave_id=leave.id, + employee_id=leave.employee_id.id, res_model=self._name, res_id=leave.id, ) diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index 499efd8f54..c878d133fd 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -525,6 +525,38 @@ def test_task_forecast_lines_consolidated_forecast(self): 0.25, ) + @freeze_time("2022-01-01 12:00:00") + def test_forecast_with_holidays(self): + self.test_task_forecast_lines_consolidated_forecast() + with Form(self.env["hr.leave"]) as form: + form.employee_id = self.employee_consultant + form.holiday_status_id = self.env.ref("hr_holidays.holiday_status_unpaid") + form.request_date_from = "2022-02-14" + form.request_date_to = "2022-02-15" + form.request_hour_from = "8" + form.request_hour_to = "18" + leave_request = form.save() + # validating the leave request will recompute the forecast lines for + # the employee capactities (actually delete the existing ones and + # create new ones -> we check that the project task lines are + # automatically related to the new newly created employee role lines. + leave_request.action_validate() + forecast_lines = self.env["forecast.line"].search( + [ + ("employee_id", "=", self.employee_consultant.id), + ("res_model", "=", "hr.employee.forecast.role"), + ("date_from", ">=", "2022-02-14"), + ("date_to", "<=", "2022-02-15"), + ] + ) + self.assertEqual(len(forecast_lines), 2) + # both new lines have now a capacity of 0 (employee is on holidays) + self.assertEqual(forecast_lines[0].forecast_hours, 0) + self.assertEqual(forecast_lines[1].forecast_hours, 0) + # first line has a negative consolidated forcast (because of the task) + self.assertEqual(forecast_lines[0].consolidated_forecast, -0.75) + self.assertEqual(forecast_lines[1].consolidated_forecast, -0) + def test_task_forecast_lines_consolidated_forecast_overallocation(self): with freeze_time("2022-01-01"): employee_forecast = self.env["forecast.line"].search( From a9b28d3f44627ebf7709ef1a46014a48dac067a9 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Tue, 11 Oct 2022 11:42:57 +0200 Subject: [PATCH 19/33] [FIX] project_forecast_line: handle lack of project status In some cases you can have a project without a status set -> in this case we don't want to generate forecast lines (and we don't want a crash either) --- project_forecast_line/models/project_task.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py index d6ff3535f6..6cc02cb507 100644 --- a/project_forecast_line/models/project_task.py +++ b/project_forecast_line/models/project_task.py @@ -80,6 +80,9 @@ def _update_forecast_lines(self): if not forecast_type: _logger.info("skip task %s: no forecast for project state", task) continue # closed / cancelled stage + elif not task.project_id.project_status: + _logger.info("skip task %s: no project status set", task) + continue # not status on project elif task.sale_line_id: sale_state = task.sale_line_id.state if sale_state == "cancel": From 2a45f7950b1f6aed1d4beec677d1b87533d0ed4a Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Thu, 15 Sep 2022 11:50:32 +0300 Subject: [PATCH 20/33] Revert "[15.0][IMP] project_forecast_line: setting to control consumption states" This reverts commit 82f5f363fe43f83c82a4a374314bf489fe12bb99. --- project_forecast_line/models/forecast_line.py | 10 +--------- project_forecast_line/models/res_company.py | 16 ---------------- .../models/res_config_settings.py | 4 +--- .../views/res_config_settings_views.xml | 11 ----------- 4 files changed, 2 insertions(+), 39 deletions(-) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index bd72233842..2d76430126 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -90,13 +90,8 @@ 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") @@ -121,10 +116,7 @@ def _compute_employee_forecast_line_id(self): (line.employee_id.id, line.date_from, line.forecast_role_id.id) ] = line.id for rec in self: - if ( - rec.type in consumption_states - and rec.res_model != "hr.employee.forecast.role" - ): + if rec.type == "confirmed" 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 ) diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py index c3a41c0661..4d88fee87d 100644 --- a/project_forecast_line/models/res_company.py +++ b/project_forecast_line/models/res_company.py @@ -14,22 +14,6 @@ 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 4c7cc90534..4ac43649ec 100644 --- a/project_forecast_line/models/res_config_settings.py +++ b/project_forecast_line/models/res_config_settings.py @@ -12,9 +12,7 @@ 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/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml index 61b15942a2..e273587afb 100644 --- a/project_forecast_line/views/res_config_settings_views.xml +++ b/project_forecast_line/views/res_config_settings_views.xml @@ -50,17 +50,6 @@ class="o_field_integer o_field_number o_field_widget o_input oe_inline col-lg-2" /> </div> - <div class="mt8"> - <label for="forecast_consumption_states" /> - <div class="text-muted"> - Select the states for which the consumption is confirmed or not - </div> - <field - name="forecast_consumption_states" - required="1" - class="o_light_label" - /> - </div> </div> </div> </div> From 736ad37d78f3a07226cf1731b2c97dfbc6a3a002 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Thu, 15 Sep 2022 14:11:41 +0300 Subject: [PATCH 21/33] [FIX] project_forecast_line: The idea is to have two consolidated forecast fields, instead of setting. --- project_forecast_line/models/forecast_line.py | 86 ++++++--- .../tests/test_forecast_line.py | 168 +++++++++++------- .../views/forecast_line_views.xml | 6 +- 3 files changed, 172 insertions(+), 88 deletions(-) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 2d76430126..5b38e7fb17 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -61,11 +61,18 @@ class ForecastLine(models.Model): "holidays, sales, or tasks. ", ) consolidated_forecast = fields.Float( + help="Consolidated forecast for lines of all types consumed", + digits=(12, 5), + store=True, + compute="_compute_consolidated_forecast", + ) + confirmed_consolidated_forecast = fields.Float( + string="Confirmed lines consolidated forecast", + help="Consolidated forecast for lines of type confirmed", digits=(12, 5), store=True, compute="_compute_consolidated_forecast", ) - currency_id = fields.Many2one(related="company_id.currency_id", store=True) company_id = fields.Many2one( "res.company", required=True, default=lambda s: s.env.company @@ -112,11 +119,15 @@ 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.forecast_role_id.id) - ] = line.id + employee_id = line.employee_id + date_from = line.date_from + forecast_role_id = line.forecast_role_id + capacities[(employee_id.id, date_from, forecast_role_id.id)] = line.id for rec in self: - if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role": + if ( + rec.type in ("forecast", "confirmed") + 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 ) @@ -132,34 +143,55 @@ def _compute_employee_forecast_line_id(self): else: rec.employee_resource_forecast_line_id = False - def _convert_forecast(self, data): - """ - Converts consolidated forecast from hours to days - """ - self.ensure_one() + def _get_grouped_line_values(self): + data = {} + grouped_line_result = self.env["forecast.line"].read_group( + [("employee_resource_forecast_line_id", "in", self.ids)], + fields=["forecast_hours"], + groupby=["employee_resource_forecast_line_id", "type"], + lazy=False, + ) + for d in grouped_line_result: + line_id = d["employee_resource_forecast_line_id"][0] + if line_id not in data: + data[line_id] = {"confirmed": 0, "forecast": 0} + data[line_id][d["type"]] += d["forecast_hours"] + return data + + def _convert_hours_to_days(self, hours): to_convert_uom = self.env.ref("uom.product_uom_day") project_time_mode_id = self.company_id.project_time_mode_id - if self.res_model != "hr.employee.forecast.role": - return -project_time_mode_id._compute_quantity( - self.forecast_hours, to_convert_uom, round=False - ) - else: - forecast_hours = self.forecast_hours + data.get(self.id, 0) - return project_time_mode_id._compute_quantity( - forecast_hours, to_convert_uom, round=False - ) + return project_time_mode_id._compute_quantity( + hours, to_convert_uom, round=False + ) @api.depends("employee_resource_consumption_ids.forecast_hours", "forecast_hours") def _compute_consolidated_forecast(self): - data = {} - for d in self.env["forecast.line"].read_group( - [("employee_resource_forecast_line_id", "in", self.ids)], - fields=["forecast_hours"], - groupby=["employee_resource_forecast_line_id"], - ): - data[d["employee_resource_forecast_line_id"][0]] = d["forecast_hours"] + grouped_lines_values = self._get_grouped_line_values() for rec in self: - rec.consolidated_forecast = rec._convert_forecast(data) + if rec.res_model != "hr.employee.forecast.role": + rec.consolidated_forecast = ( + self._convert_hours_to_days(rec.forecast_hours) * -1 + ) + rec.confirmed_consolidated_forecast = ( + self._convert_hours_to_days(rec.forecast_hours) * -1 + ) + else: + resource_forecast = grouped_lines_values.get(rec.id, 0) + confirmed_forecast = ( + resource_forecast.get("confirmed", 0) if resource_forecast else 0 + ) + consumable_forecast = ( + confirmed_forecast + resource_forecast.get("forecast", 0) + if resource_forecast + else 0 + ) + rec.consolidated_forecast = self._convert_hours_to_days( + rec.forecast_hours + consumable_forecast + ) + rec.confirmed_consolidated_forecast = self._convert_hours_to_days( + rec.forecast_hours + confirmed_forecast + ) def prepare_forecast_lines( self, diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index c878d133fd..53cf43e116 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -4,10 +4,11 @@ from freezegun import freeze_time -from odoo.tests.common import Form, SavepointCase +from odoo.tests.common import Form, TransactionCase, tagged -class BaseForecastLineTest(SavepointCase): +@tagged("-at_install", "post_install") +class BaseForecastLineTest(TransactionCase): @classmethod @freeze_time("2022-01-01") def setUpClass(cls): @@ -489,41 +490,91 @@ def setUpClass(cls): } ) + def _get_employee_forecast(self): + 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] + forecast_pm = employee_forecast.filtered( + lambda l: l.res_model == "hr.employee.forecast.role" + and l.forecast_role_id == self.role_pm + )[0] + return forecast_consultant, forecast_pm + def test_task_forecast_lines_consolidated_forecast(self): - with freeze_time("2022-01-01"): - employee_forecast = self.env["forecast.line"].search( - [ - ("employee_id", "=", self.employee_consultant.id), - ("date_from", "=", "2022-02-14"), - ] - ) - self.assertEqual(len(employee_forecast), 1) - project = self.env["project.project"].create({"name": "TestProject"}) - # set project in stage "in progress" to get confirmed forecast - project.project_status = self.env.ref( - "project_status.project_status_in_progress" - ) - task = self.env["project.task"].create( - { - "name": "Task1", - "project_id": project.id, - "forecast_role_id": self.role_consultant.id, - "forecast_date_planned_start": "2022-02-14", - "forecast_date_planned_end": "2022-02-14", - "planned_hours": 6, - } - ) - task.remaining_hours = 6 - task.user_id = self.user_consultant - forecast = self.env["forecast.line"].search([("task_id", "=", task.id)]) - self.assertEqual(len(forecast), 1) - # using assertEqual on purpose here - self.assertEqual(forecast.forecast_hours, -6.0) - self.assertAlmostEqual(forecast.consolidated_forecast, 0.75) - self.assertEqual( - forecast.employee_resource_forecast_line_id.consolidated_forecast, - 0.25, - ) + 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_1 = self.env["project.project"].create({"name": "TestProject1"}) + # set project in stage "to do" to get forecast + project_1.stage_id = self.env.ref("project.project_project_stage_0") + task_values = { + "project_id": project_1.id, + "forecast_role_id": self.role_consultant.id, + "forecast_date_planned_start": date.today(), + "forecast_date_planned_end": date.today(), + "planned_hours": 8, + } + task_values.update({"name": "Task1"}) + task_1 = self.env["project.task"].create(task_values) + task_1.user_ids = self.user_consultant + task_values.update({"name": "Task2"}) + task_2 = self.env["project.task"].create(task_values) + task_2.user_ids = self.user_consultant + project_2 = self.env["project.project"].create({"name": "TestProject2"}) + # set project in stage "in progress" to get forecast + project_2.stage_id = self.env.ref("project.project_project_stage_1") + task_values.update({"project_id": project_2.id, "name": "Task3"}) + task_3 = self.env["project.task"].create(task_values) + task_3.user_ids = self.user_consultant + task_values.update({"name": "Task4"}) + task_4 = self.env["project.task"].create(task_values) + task_4.user_ids = self.user_consultant + forecast = self.env["forecast.line"].search( + [("task_id", "in", (task_1.id, task_2.id, task_3.id, task_4.id))] + ) + self.assertEqual(len(forecast), 4) + # as we have multiple tasks to check we will divide by 4 + self.assertAlmostEqual(sum(f.forecast_hours for f in forecast), -32.0) + self.assertAlmostEqual(sum(f.consolidated_forecast for f in forecast), 4.0) + self.assertAlmostEqual( + sum(f.confirmed_consolidated_forecast for f in forecast), 4.0 + ) + consol_employee_forecast = sum( + f.employee_resource_forecast_line_id.consolidated_forecast for f in forecast + ) + confir_employee_forecast = sum( + f.employee_resource_forecast_line_id.confirmed_consolidated_forecast + for f in forecast + ) + self.assertAlmostEqual(consol_employee_forecast, -13.0) + self.assertAlmostEqual(confir_employee_forecast, -5.0) + forecast_consultant, forecast_pm = self._get_employee_forecast() + self.assertEqual(forecast_consultant.forecast_hours, 6.0) + self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -3.25) + self.assertAlmostEqual( + forecast_consultant.confirmed_consolidated_forecast, -1.25 + ) + self.assertEqual(forecast_pm.forecast_hours, 2.0) + self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) + self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25) @freeze_time("2022-01-01 12:00:00") def test_forecast_with_holidays(self): @@ -588,10 +639,15 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self): # using assertEqual on purpose here self.assertEqual(forecast.forecast_hours, -10.0) self.assertEqual(forecast.consolidated_forecast, 1.25) + self.assertEqual(forecast.confirmed_consolidated_forecast, 1.25) self.assertEqual( forecast.employee_resource_forecast_line_id.consolidated_forecast, -0.25, ) + self.assertEqual( + forecast.employee_resource_forecast_line_id.confirmed_consolidated_forecast, + -0.25, + ) def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks( self, @@ -646,6 +702,10 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks forecast1.employee_resource_forecast_line_id.consolidated_forecast, -0.75, ) + self.assertAlmostEqual( + forecast1.employee_resource_forecast_line_id.confirmed_consolidated_forecast, + -0.75, + ) def test_task_forecast_lines_employee_different_roles(self): """ @@ -702,22 +762,16 @@ def test_task_forecast_lines_employee_different_roles(self): # 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(task_forecast.confirmed_consolidated_forecast, 1.0) + forecast_consultant, forecast_pm = self._get_employee_forecast() 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.assertAlmostEqual( + forecast_consultant.confirmed_consolidated_forecast, -0.25 + ) self.assertEqual(forecast_pm.forecast_hours, 2.0) self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) + self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25) def test_task_forecast_lines_employee_main_role(self): """ @@ -775,19 +829,13 @@ def test_task_forecast_lines_employee_main_role(self): # 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(task_forecast.confirmed_consolidated_forecast, 1.0) + forecast_consultant, forecast_pm = self._get_employee_forecast() 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.assertAlmostEqual( + forecast_consultant.confirmed_consolidated_forecast, -0.25 + ) self.assertEqual(forecast_pm.forecast_hours, 2.0) self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) + self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25) diff --git a/project_forecast_line/views/forecast_line_views.xml b/project_forecast_line/views/forecast_line_views.xml index 7b92e72d75..a9238b8bdd 100644 --- a/project_forecast_line/views/forecast_line_views.xml +++ b/project_forecast_line/views/forecast_line_views.xml @@ -64,7 +64,8 @@ <field name="date_to" /> <field name="forecast_hours" /> <field name="cost" /> - <field name="consolidated_forecast" /> + <field name="confirmed_consolidated_forecast" optional="show" /> + <field name="consolidated_forecast" optional="show" /> <field name="forecast_role_id" /> <field name="currency_id" invisible="1" /> </tree> @@ -76,6 +77,7 @@ <graph type="bar" stacked="0"> <field name="date_from" type="row" /> <field name="forecast_role_id" type="col" /> + <field name="confirmed_consolidated_forecast" type="measure" /> <field name="consolidated_forecast" type="measure" /> <field name="cost" type="measure" /> <field name="forecast_hours" type="measure" /> @@ -91,6 +93,7 @@ <field name="date_from" type="row" /> <field name="employee_id" type="col" /> <field name="project_id" type="col" /> + <field name="confirmed_consolidated_forecast" type="measure" /> <field name="consolidated_forecast" type="measure" /> </graph> </field> @@ -101,6 +104,7 @@ <pivot> <field name="date_from" type="col" /> <field name="forecast_role_id" type="row" /> + <field name="confirmed_consolidated_forecast" type="measure" /> <field name="consolidated_forecast" type="measure" /> <field name="cost" type="measure" /> <field name="forecast_hours" type="measure" /> From 9984b0c89a0b4a4dab5bc1749ec18663a303a6e8 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Fri, 16 Sep 2022 16:04:34 +0300 Subject: [PATCH 22/33] [FIX] project_forecast_line: give sudo rights on hr.leave to create forecast --- project_forecast_line/models/hr_leave.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py index e74a7699af..0eafedb057 100644 --- a/project_forecast_line/models/hr_leave.py +++ b/project_forecast_line/models/hr_leave.py @@ -29,7 +29,13 @@ def _update_forecast_lines(self): [("res_id", "in", self.ids), ("res_model", "=", self._name)] ).unlink() leaves = self.filtered_domain([("state", "!=", "refuse")]) - for leave in leaves: + # we need to use sudo here, because forecast line creation + # requires access to fields declared on hr.employee + # we don't want to restrict them with `groups="hr.group_hr_user"` + # as this will require giving access to employee app, + # which isn't wanted on some projects + # for more details see here: .../addons/hr/models/hr_employee.py#L22 + for leave in leaves.sudo(): if not leave.employee_id.main_role_id: _logger.warning( "No forecast role for employee %s (%s)", From 86694a569092ae0d76a1e0ccf4bde3b0e5a23f3e Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Tue, 11 Oct 2022 12:32:17 +0200 Subject: [PATCH 23/33] fix the computation of the field and the tests --- project_forecast_line/models/forecast_line.py | 17 ++--- .../tests/test_forecast_line.py | 70 +++++++++++++------ 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py index 5b38e7fb17..c192239b67 100644 --- a/project_forecast_line/models/forecast_line.py +++ b/project_forecast_line/models/forecast_line.py @@ -173,24 +173,25 @@ def _compute_consolidated_forecast(self): rec.consolidated_forecast = ( self._convert_hours_to_days(rec.forecast_hours) * -1 ) - rec.confirmed_consolidated_forecast = ( - self._convert_hours_to_days(rec.forecast_hours) * -1 - ) + if rec.type == "confirmed": + rec.confirmed_consolidated_forecast = rec.consolidated_forecast + else: + rec.confirmed_consolidated_forecast = 0.0 else: resource_forecast = grouped_lines_values.get(rec.id, 0) - confirmed_forecast = ( + confirmed = ( resource_forecast.get("confirmed", 0) if resource_forecast else 0 ) - consumable_forecast = ( - confirmed_forecast + resource_forecast.get("forecast", 0) + unconfirmed = ( + confirmed + resource_forecast.get("forecast", 0) if resource_forecast else 0 ) rec.consolidated_forecast = self._convert_hours_to_days( - rec.forecast_hours + consumable_forecast + rec.forecast_hours + unconfirmed ) rec.confirmed_consolidated_forecast = self._convert_hours_to_days( - rec.forecast_hours + confirmed_forecast + rec.forecast_hours + confirmed ) def prepare_forecast_lines( diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index 53cf43e116..8f05e4902a 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -505,7 +505,9 @@ def _get_employee_forecast(self): )[0] return forecast_consultant, forecast_pm + @freeze_time("2022-02-14 12:00:00") def test_task_forecast_lines_consolidated_forecast(self): + # set the consultant employee to 75% consultant and 25% PM self.env["hr.employee.forecast.role"].create( { "employee_id": self.employee_consultant.id, @@ -522,14 +524,19 @@ def test_task_forecast_lines_consolidated_forecast(self): ] ) consultant_role.rate = 75 + + # Create 2 project and 2 tasks with role consultant with 8h planned on + # 1 day, assigned to the consultant + # + # Projet 1 is in TODO (not confirmed forecast) project_1 = self.env["project.project"].create({"name": "TestProject1"}) # set project in stage "to do" to get forecast project_1.stage_id = self.env.ref("project.project_project_stage_0") task_values = { "project_id": project_1.id, "forecast_role_id": self.role_consultant.id, - "forecast_date_planned_start": date.today(), - "forecast_date_planned_end": date.today(), + "forecast_date_planned_start": "2022-02-14", + "forecast_date_planned_end": "2022-02-14", "planned_hours": 8, } task_values.update({"name": "Task1"}) @@ -538,8 +545,9 @@ def test_task_forecast_lines_consolidated_forecast(self): task_values.update({"name": "Task2"}) task_2 = self.env["project.task"].create(task_values) task_2.user_ids = self.user_consultant + + # Project 2 is in stage "in progress" to get forecast project_2 = self.env["project.project"].create({"name": "TestProject2"}) - # set project in stage "in progress" to get forecast project_2.stage_id = self.env.ref("project.project_project_stage_1") task_values.update({"project_id": project_2.id, "name": "Task3"}) task_3 = self.env["project.task"].create(task_values) @@ -547,30 +555,40 @@ def test_task_forecast_lines_consolidated_forecast(self): task_values.update({"name": "Task4"}) task_4 = self.env["project.task"].create(task_values) task_4.user_ids = self.user_consultant + + # check forecast lines forecast = self.env["forecast.line"].search( [("task_id", "in", (task_1.id, task_2.id, task_3.id, task_4.id))] ) self.assertEqual(len(forecast), 4) - # as we have multiple tasks to check we will divide by 4 - self.assertAlmostEqual(sum(f.forecast_hours for f in forecast), -32.0) - self.assertAlmostEqual(sum(f.consolidated_forecast for f in forecast), 4.0) - self.assertAlmostEqual( - sum(f.confirmed_consolidated_forecast for f in forecast), 4.0 + self.assertEqual( + forecast.mapped("forecast_hours"), + [ + -8.0, + ] + * 4, ) - consol_employee_forecast = sum( - f.employee_resource_forecast_line_id.consolidated_forecast for f in forecast + # consolidated forecast is in days of 8 hours + self.assertEqual(forecast.mapped("consolidated_forecast"), [1.0] * 4) + self.assertEqual( + forecast.filtered(lambda r: r.type == "forecast").mapped( + "confirmed_consolidated_forecast" + ), + [0.0] * 2, ) - confir_employee_forecast = sum( - f.employee_resource_forecast_line_id.confirmed_consolidated_forecast - for f in forecast + self.assertEqual( + forecast.filtered(lambda r: r.type == "confirmed").mapped( + "confirmed_consolidated_forecast" + ), + [1.0] * 2, ) - self.assertAlmostEqual(consol_employee_forecast, -13.0) - self.assertAlmostEqual(confir_employee_forecast, -5.0) forecast_consultant, forecast_pm = self._get_employee_forecast() self.assertEqual(forecast_consultant.forecast_hours, 6.0) - self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -3.25) self.assertAlmostEqual( - forecast_consultant.confirmed_consolidated_forecast, -1.25 + forecast_consultant.consolidated_forecast, 1.0 * 75 / 100 - 4 + ) + self.assertAlmostEqual( + forecast_consultant.confirmed_consolidated_forecast, 1.0 * 75 / 100 - 2 ) self.assertEqual(forecast_pm.forecast_hours, 2.0) self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) @@ -600,13 +618,17 @@ def test_forecast_with_holidays(self): ("date_to", "<=", "2022-02-15"), ] ) - self.assertEqual(len(forecast_lines), 2) + # 1 line per role per day -> 4 lines + self.assertEqual(len(forecast_lines), 2 * 2) + forecast_lines_consultant = forecast_lines.filtered( + lambda r: r.forecast_role_id == self.role_consultant + ) # both new lines have now a capacity of 0 (employee is on holidays) - self.assertEqual(forecast_lines[0].forecast_hours, 0) - self.assertEqual(forecast_lines[1].forecast_hours, 0) - # first line has a negative consolidated forcast (because of the task) - self.assertEqual(forecast_lines[0].consolidated_forecast, -0.75) - self.assertEqual(forecast_lines[1].consolidated_forecast, -0) + self.assertEqual(forecast_lines_consultant[0].forecast_hours, 0) + self.assertEqual(forecast_lines_consultant[1].forecast_hours, 0) + # first line has a negative consolidated forecast (because of the task) + self.assertEqual(forecast_lines_consultant[0].consolidated_forecast, 0 - 4) + self.assertEqual(forecast_lines_consultant[1].consolidated_forecast, -0) def test_task_forecast_lines_consolidated_forecast_overallocation(self): with freeze_time("2022-01-01"): @@ -707,6 +729,7 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks -0.75, ) + @freeze_time("2022-01-03 12:00:00") def test_task_forecast_lines_employee_different_roles(self): """ Test forecast lines when employee has different roles. @@ -773,6 +796,7 @@ def test_task_forecast_lines_employee_different_roles(self): self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25) self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25) + @freeze_time("2022-01-03 12:00:00") def test_task_forecast_lines_employee_main_role(self): """ Test forecast lines when employee has different roles From d696a91635705af689c544ee19229a34ff1a1602 Mon Sep 17 00:00:00 2001 From: ntsirintanis <ntsirintanis@therp.nl> Date: Wed, 9 Nov 2022 15:03:19 +0100 Subject: [PATCH 24/33] [14.0][IMP][WIP] project_forecast_line: fix backport unit tests --- .../tests/test_forecast_line.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py index 8f05e4902a..45117c503f 100644 --- a/project_forecast_line/tests/test_forecast_line.py +++ b/project_forecast_line/tests/test_forecast_line.py @@ -4,11 +4,11 @@ from freezegun import freeze_time -from odoo.tests.common import Form, TransactionCase, tagged +from odoo.tests.common import Form, SavepointCase, tagged @tagged("-at_install", "post_install") -class BaseForecastLineTest(TransactionCase): +class BaseForecastLineTest(SavepointCase): @classmethod @freeze_time("2022-01-01") def setUpClass(cls): @@ -369,6 +369,10 @@ def test_confirm_order_sale_order_create_project_task_with_forecast_line(self): so.action_confirm() line = so.order_line[0] task = self.env["project.task"].search([("sale_line_id", "=", line.id)]) + # Give a project_status to the project + task.project_id.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) forecast_lines = self.env["forecast.line"].search( [("res_id", "=", task.id), ("res_model", "=", "project.task")] ) @@ -409,6 +413,10 @@ def test_timesheet_forecast_lines(self): with freeze_time("2022-02-14"): line = so.order_line[0] task = self.env["project.task"].search([("sale_line_id", "=", line.id)]) + # Give a project_status to the project + task.project_id.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) # timesheet 1d self.env["account.analytic.line"].create( { @@ -530,8 +538,8 @@ def test_task_forecast_lines_consolidated_forecast(self): # # Projet 1 is in TODO (not confirmed forecast) project_1 = self.env["project.project"].create({"name": "TestProject1"}) - # set project in stage "to do" to get forecast - project_1.stage_id = self.env.ref("project.project_project_stage_0") + # set project in stage "Pending" to get confirmed forecast + project_1.project_status = self.env.ref("project_status.project_status_pending") task_values = { "project_id": project_1.id, "forecast_role_id": self.role_consultant.id, @@ -541,20 +549,22 @@ def test_task_forecast_lines_consolidated_forecast(self): } task_values.update({"name": "Task1"}) task_1 = self.env["project.task"].create(task_values) - task_1.user_ids = self.user_consultant + task_1.user_id = self.user_consultant task_values.update({"name": "Task2"}) task_2 = self.env["project.task"].create(task_values) - task_2.user_ids = self.user_consultant + task_2.user_id = self.user_consultant # Project 2 is in stage "in progress" to get forecast project_2 = self.env["project.project"].create({"name": "TestProject2"}) - project_2.stage_id = self.env.ref("project.project_project_stage_1") + project_2.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) task_values.update({"project_id": project_2.id, "name": "Task3"}) task_3 = self.env["project.task"].create(task_values) - task_3.user_ids = self.user_consultant + task_3.user_id = self.user_consultant task_values.update({"name": "Task4"}) task_4 = self.env["project.task"].create(task_values) - task_4.user_ids = self.user_consultant + task_4.user_id = self.user_consultant # check forecast lines forecast = self.env["forecast.line"].search( From 8f27174a66a104252b4eaea42c5e1904c6f079e4 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Wed, 31 Aug 2022 17:13:22 +0200 Subject: [PATCH 25/33] [FIX] project_forecast_line consolidated capacity in some cases, we could have capacity consumed by a task which was not matching a work capacity line in the period because of a faulty optimisation we were making which skipped the creation of lines with a capacity of 0 -> then we had no line on which to compute the negative consolidated capacity. We remove the optimisation to fix this case and show the problematic periods in the consolidated capacity graphs From 877e74fa5a4af781dc8438f041cab34af824019f Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Tue, 30 Aug 2022 11:39:39 +0300 Subject: [PATCH 26/33] [15.0][FIX] project_forecast_line: take roles into account on calc From f58ceb1234b5433ce7efb6cb306486125fc289d4 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Mon, 5 Sep 2022 11:03:52 +0300 Subject: [PATCH 27/33] [15.0][IMP] project_forecast_line: setting to control consumption states From a01987b839d3745445c68e36c3a0733a9dd6954d Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com> Date: Wed, 31 Aug 2022 17:13:22 +0200 Subject: [PATCH 28/33] [FIX] project_forecast_line consolidated capacity in some cases, we could have capacity consumed by a task which was not matching a work capacity line in the period because of a faulty optimisation we were making which skipped the creation of lines with a capacity of 0 -> then we had no line on which to compute the negative consolidated capacity. We remove the optimisation to fix this case and show the problematic periods in the consolidated capacity graphs From 3c08a467627e0d8a822bfabb9149b4dfd067dce9 Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Tue, 30 Aug 2022 11:39:39 +0300 Subject: [PATCH 29/33] [15.0][FIX] project_forecast_line: take roles into account on calc From 630c358a17833e8499648b9bbe36bde64485673c Mon Sep 17 00:00:00 2001 From: Maksym Yankin <yankinmk@gmail.com> Date: Mon, 5 Sep 2022 11:03:52 +0300 Subject: [PATCH 30/33] [15.0][IMP] project_forecast_line: setting to control consumption states From 759e42c1ccff1a5095035c77098b19e363e9826b Mon Sep 17 00:00:00 2001 From: Tom Blauwendraat <tom@sunflowerweb.nl> Date: Wed, 14 Dec 2022 19:57:59 +0100 Subject: [PATCH 31/33] [FIX] This fails when the field is being read by a user with readonly access because then rec is a hr.employee.public record --- project_forecast_line/models/hr_employee.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py index 9afb519899..b2398d9b29 100644 --- a/project_forecast_line/models/hr_employee.py +++ b/project_forecast_line/models/hr_employee.py @@ -24,6 +24,9 @@ def _compute_main_role_id(self): # can"t store as it depends on current date today = fields.Date.context_today(self) for rec in self: + if not hasattr(rec, 'role_ids'): + rec.main_role_id = None + continue rec.main_role_id = rec.role_ids.filtered( lambda r: r.date_start <= today and not r.date_end From 935a0e851f10043d9c12b595b482ea85f4c3ef2e Mon Sep 17 00:00:00 2001 From: Tom Blauwendraat <tom@sunflowerweb.nl> Date: Wed, 14 Dec 2022 20:02:04 +0100 Subject: [PATCH 32/33] fixup! [FIX] This fails when the field is being read by a user with readonly access because then rec is a hr.employee.public record --- project_forecast_line/models/hr_employee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py index b2398d9b29..2d9ef31554 100644 --- a/project_forecast_line/models/hr_employee.py +++ b/project_forecast_line/models/hr_employee.py @@ -24,7 +24,7 @@ def _compute_main_role_id(self): # can"t store as it depends on current date today = fields.Date.context_today(self) for rec in self: - if not hasattr(rec, 'role_ids'): + if not hasattr(rec, "role_ids"): rec.main_role_id = None continue rec.main_role_id = rec.role_ids.filtered( From 1cfd05e66f5b6da3d9d322d27edf90099f983666 Mon Sep 17 00:00:00 2001 From: Tom Blauwendraat <tom@sunflowerweb.nl> Date: Wed, 14 Dec 2022 20:09:40 +0100 Subject: [PATCH 33/33] fixup! fixup! [FIX] This fails when the field is being read by a user with readonly access because then rec is a hr.employee.public record --- project_forecast_line/models/hr_employee.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py index 2d9ef31554..451338ccd9 100644 --- a/project_forecast_line/models/hr_employee.py +++ b/project_forecast_line/models/hr_employee.py @@ -17,16 +17,14 @@ class HrEmployee(models.Model): role_ids = fields.One2many("hr.employee.forecast.role", "employee_id") main_role_id = fields.Many2one( - "forecast.role", compute="_compute_main_role_id", ondelete="restrict" + "forecast.role", compute="_compute_main_role_id", ondelete="restrict", + compute_sudo=True ) def _compute_main_role_id(self): # can"t store as it depends on current date today = fields.Date.context_today(self) for rec in self: - if not hasattr(rec, "role_ids"): - rec.main_role_id = None - continue rec.main_role_id = rec.role_ids.filtered( lambda r: r.date_start <= today and not r.date_end