From 7803c4b8b4bd063920842434cb79f33e39267859 Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Mon, 18 Jul 2022 12:32:47 +0300 Subject: [PATCH 01/76] [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 @@ + + + + Forecast recomputation + + + code + model._cron_recompute_all() + 1 + days + -1 + + + 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 @@ + + + + + forecast + + + + confirmed + + + + + + + + + + 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 +* Maksym Yankin 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 @@ + + + + Manage Forecast Dates on Quotations + + + + + Forecast multi-company + + [('company_id', 'in', company_ids)] + + 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+!v8>9?Cy_Ew*t4i>PY$Zd?AYyxlSL^p9Ihy8~si1SGy_2dcgnl8(h>Yo=QGs9DtG<5FX=p_aC9x4nxQf^7ekN`6}7ugs~l9-mY<*ha3FIP7jUp* z*v@d->(`eTA>@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>UHWv4d|YM|lam-@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_I2G zkj3rokb{-Mg#~*rE6JObYU#?sep^#@HX&!eci%eN+xz^$DEe=uJ46oCRjfV4-UY^( z+gt2Gm)>`_Q~!K=H}hsgx7%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`MAvCp|BFMq4$i%|JLN{4#wFr+=Gc+7*3_c=e#d9fAl+C7_XQ0$W zczAf~>gs}zwhs69aySV&Is7s*m7hMbJEGbdGYnEPGYuHOh>CJ(NSz-WO&{FX z@|%!%pIZ6#3nf1SAeWVq33BmnGd49fwYGk`IeA@_!$6j@D)Xwt;i+^cc6`;RGoE*# zug}fh{kD;mjg8Gm<_@QdWK7}ks*8(@c8Efpq+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@oCI3CEAXg%Y*t2CC=lRa$D=T3T90KcI)*I9!mafMAq^ zf4B4woc#F%V`HnYtK-nOK%-&I%)`E8-}?IKmr$aS;&}c8Hh$l9GaSy_|P{bwq>6S{*Ln zoPH|K^un<}{p5#7Gl(x~A`z-)K`UgC&JKf74_mG4;BaSg+)DlFigrQgs2F&}QhKlv z!EjDaV{_Ds3MqJRp{vrNN6u~X`8QsOYW6)bn0_aK-M_9!vwz& zA&p5%h+OJVlV_OQiz+oiqb02yKGoMxI_aR%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>E{bQcJ6oU|H+c!T%qMJ!FAr65ws~^yI>|C6Iu+ zGWLANu?HHit|f-iB1s~0a=*uILaxJO)!AZ2pYNhgO<6fOF4jek&CJ|FAXble=UZD_ zkvdQ{SJw^F;d;?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+^yAv2C86aa+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!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?waL~zKhT|}_!0baK$UhlM z{vfRY0lhMqdncnkl0m)^E#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~t{fDBCQ5*PY;A3oIJTP95C%xVwX8H9wE;fcEk2hRgdl~4hAVGb!<$h7?9)JtWI z9OtPoAw4;zWD^iKL&&GViuW|vpa1H+2DprP7akEPd9OAgF!1fk#JoXyON(l>{nVot zsotKR=&kjF%WbvV`fb0+kiTNc3v}&3F1;#qBN z?e*jH4JaxaN{R||JsexU;#`{SLAl(&rG0Giyf4OPoF(k~Gyq@zQ(vB6ur|KVh02i#$PUi~bNBie2hUnO=%Is{)e+hW*} uAIkjsQl(|Q&Ap!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 @@ + + + + + + +Project Forecast Lines + + + +
+

Project Forecast Lines

+ + +

Beta License: AGPL-3 OCA/project Translate me on Weblate Try me on Runbot

+

This module adds forecast line to project.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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 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/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 @@ + + + + forecast.line + + + + + + + + + + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + + Forecast + ir.actions.act_window + forecast.line + tree,graph,pivot + + + Forecast (Consolidated) + ir.actions.act_window + forecast.line + graph,pivot,tree + + + [('employee_id', '!=', False)] + {'graph_groupbys': ['date_from:month', 'project_id']} + + + + + + + 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 @@ + + + + forecast.role + + + + + + + + + forecast.role + + + + + + + + forecast.role + +
+ + + + + + +
+
+
+ + Forecast Roles + ir.actions.act_window + forecast.role + tree,form + + + + +
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 @@ + + + + hr.employee + + + + + + + + + + + + + + + + + + + + hr.job + + + + + + + + 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 @@ + + + + product.template + + + + + + + + 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 @@ + + + + project.project.stage + + + + + + + + + project.project.stage + + + + + + + + + + project.project.stage + + + + + + + + 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 @@ + + + + project.task + + + + + + + + + + + + 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 @@ + + + + res.config.settings.view.form.inherit.forecast + res.config.settings + + + +
+

Forecast Management

+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Settings + ir.actions.act_window + res.config.settings + form + inline + {'module': 'forecast', 'bin_size': False} + + + +
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 @@ + + + + sale.order + + + + + + + + + + + + + + + + + + From c457f79d64e0020e00411fe4406831a0187a82b5 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Wed, 17 Aug 2022 06:25:18 +0000 Subject: [PATCH 02/76] [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 e497ec2c28b8094fcf4d69eb741e6f14023d264b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 17 Aug 2022 06:28:49 +0000 Subject: [PATCH 03/76] [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 `_. +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp SA + +Contributors +~~~~~~~~~~~~ + +* Alexandre Fayolle +* Maksym Yankin + +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 `_ 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 @@ - - + + - - + + Project Forecast Lines