From ecb88c60be91fc95095b06eb1cfb04d196b24341 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Mon, 18 Jul 2022 12:32:47 +0300
Subject: [PATCH 01/33] [15.0][ADD] project_forecast_line

---
 project_forecast_line/README.rst              |   1 +
 project_forecast_line/__init__.py             |   1 +
 project_forecast_line/__manifest__.py         |  28 +
 project_forecast_line/data/ir_cron.xml        |  14 +
 project_forecast_line/data/project_data.xml   |  19 +
 project_forecast_line/i18n/fr.po              | 503 ++++++++++++++
 .../i18n/project_forecast_line.pot            | 470 ++++++++++++++
 project_forecast_line/models/__init__.py      |  14 +
 .../models/account_analytic_line.py           |  18 +
 project_forecast_line/models/forecast_line.py | 322 +++++++++
 project_forecast_line/models/forecast_role.py |  11 +
 project_forecast_line/models/hr_employee.py   | 148 +++++
 project_forecast_line/models/hr_leave.py      |  78 +++
 .../models/product_template.py                |   9 +
 .../models/project_project.py                 |  18 +
 .../models/project_project_stage.py           |  21 +
 project_forecast_line/models/project_task.py  | 155 +++++
 project_forecast_line/models/res_company.py   |  25 +
 .../models/res_config_settings.py             |  19 +
 .../models/resource_calendar_leaves.py        |  32 +
 project_forecast_line/models/sale_order.py    |  32 +
 .../models/sale_order_line.py                 | 137 ++++
 project_forecast_line/readme/CONTRIBUTORS.rst |   2 +
 project_forecast_line/readme/DESCRIPTION.rst  |   3 +
 .../security/forecast_line_security.xml       |  13 +
 .../security/ir.model.access.csv              |   6 +
 .../static/description/icon.png               | Bin 0 -> 4738 bytes
 .../static/description/index.html             | 423 ++++++++++++
 project_forecast_line/tests/__init__.py       |   1 +
 .../tests/test_forecast_line.py               | 612 ++++++++++++++++++
 .../views/forecast_line_views.xml             | 154 +++++
 .../views/forecast_role_views.xml             |  48 ++
 .../views/hr_employee_views.xml               |  32 +
 project_forecast_line/views/product_views.xml |  12 +
 .../views/project_project_stage_views.xml     |  34 +
 .../views/project_task_views.xml              |  16 +
 .../views/res_config_settings_views.xml       |  78 +++
 .../views/sale_order_views.xml                |  48 ++
 38 files changed, 3557 insertions(+)
 create mode 100644 project_forecast_line/README.rst
 create mode 100644 project_forecast_line/__init__.py
 create mode 100644 project_forecast_line/__manifest__.py
 create mode 100644 project_forecast_line/data/ir_cron.xml
 create mode 100644 project_forecast_line/data/project_data.xml
 create mode 100644 project_forecast_line/i18n/fr.po
 create mode 100644 project_forecast_line/i18n/project_forecast_line.pot
 create mode 100644 project_forecast_line/models/__init__.py
 create mode 100644 project_forecast_line/models/account_analytic_line.py
 create mode 100644 project_forecast_line/models/forecast_line.py
 create mode 100644 project_forecast_line/models/forecast_role.py
 create mode 100644 project_forecast_line/models/hr_employee.py
 create mode 100644 project_forecast_line/models/hr_leave.py
 create mode 100644 project_forecast_line/models/product_template.py
 create mode 100644 project_forecast_line/models/project_project.py
 create mode 100644 project_forecast_line/models/project_project_stage.py
 create mode 100644 project_forecast_line/models/project_task.py
 create mode 100644 project_forecast_line/models/res_company.py
 create mode 100644 project_forecast_line/models/res_config_settings.py
 create mode 100644 project_forecast_line/models/resource_calendar_leaves.py
 create mode 100644 project_forecast_line/models/sale_order.py
 create mode 100644 project_forecast_line/models/sale_order_line.py
 create mode 100644 project_forecast_line/readme/CONTRIBUTORS.rst
 create mode 100644 project_forecast_line/readme/DESCRIPTION.rst
 create mode 100644 project_forecast_line/security/forecast_line_security.xml
 create mode 100644 project_forecast_line/security/ir.model.access.csv
 create mode 100644 project_forecast_line/static/description/icon.png
 create mode 100644 project_forecast_line/static/description/index.html
 create mode 100644 project_forecast_line/tests/__init__.py
 create mode 100644 project_forecast_line/tests/test_forecast_line.py
 create mode 100644 project_forecast_line/views/forecast_line_views.xml
 create mode 100644 project_forecast_line/views/forecast_role_views.xml
 create mode 100644 project_forecast_line/views/hr_employee_views.xml
 create mode 100644 project_forecast_line/views/product_views.xml
 create mode 100644 project_forecast_line/views/project_project_stage_views.xml
 create mode 100644 project_forecast_line/views/project_task_views.xml
 create mode 100644 project_forecast_line/views/res_config_settings_views.xml
 create mode 100644 project_forecast_line/views/sale_order_views.xml

diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst
new file mode 100644
index 0000000000..c275fb4dbf
--- /dev/null
+++ b/project_forecast_line/README.rst
@@ -0,0 +1 @@
+TO BE GENERATED BY OCA BOT
diff --git a/project_forecast_line/__init__.py b/project_forecast_line/__init__.py
new file mode 100644
index 0000000000..0650744f6b
--- /dev/null
+++ b/project_forecast_line/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
new file mode 100644
index 0000000000..de8349b15e
--- /dev/null
+++ b/project_forecast_line/__manifest__.py
@@ -0,0 +1,28 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+{
+    "name": "Project Forecast Lines",
+    "summary": "Project Forecast Lines",
+    "version": "15.0.1.0.0",
+    "author": "Camptocamp SA, Odoo Community Association (OCA)",
+    "license": "AGPL-3",
+    "category": "Project",
+    "website": "https://github.com/OCA/project",
+    "depends": ["sale_timesheet", "sale_project", "hr_holidays"],
+    "data": [
+        "security/forecast_line_security.xml",
+        "security/ir.model.access.csv",
+        "views/sale_order_views.xml",
+        "views/hr_employee_views.xml",
+        "views/forecast_line_views.xml",
+        "views/forecast_role_views.xml",
+        "views/product_views.xml",
+        "views/project_task_views.xml",
+        "views/project_project_stage_views.xml",
+        "views/res_config_settings_views.xml",
+        "data/ir_cron.xml",
+        "data/project_data.xml",
+    ],
+    "installable": True,
+    "application": True,
+}
diff --git a/project_forecast_line/data/ir_cron.xml b/project_forecast_line/data/ir_cron.xml
new file mode 100644
index 0000000000..21563c6143
--- /dev/null
+++ b/project_forecast_line/data/ir_cron.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="cron_forecast_lines" model="ir.cron">
+        <field name="name">Forecast recomputation</field>
+        <field name="model_id" ref="project_forecast_line.model_forecast_line" />
+        <field name="user_id" ref="base.user_root" />
+        <field name="state">code</field>
+        <field name="code">model._cron_recompute_all()</field>
+        <field name="interval_number">1</field>
+        <field name="interval_type">days</field>
+        <field name="numbercall">-1</field>
+        <field name="doall" eval="False" />
+    </record>
+</odoo>
diff --git a/project_forecast_line/data/project_data.xml b/project_forecast_line/data/project_data.xml
new file mode 100644
index 0000000000..1dd857c7af
--- /dev/null
+++ b/project_forecast_line/data/project_data.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <!-- Project Stages -->
+    <record id="project.project_project_stage_0" model="project.project.stage">
+        <field name="forecast_line_type">forecast</field>
+    </record>
+
+    <record id="project.project_project_stage_1" model="project.project.stage">
+        <field name="forecast_line_type">confirmed</field>
+    </record>
+
+    <record id="project.project_project_stage_2" model="project.project.stage">
+        <field name="forecast_line_type" />
+    </record>
+
+    <record id="project.project_project_stage_3" model="project.project.stage">
+        <field name="forecast_line_type" />
+    </record>
+</odoo>
diff --git a/project_forecast_line/i18n/fr.po b/project_forecast_line/i18n/fr.po
new file mode 100644
index 0000000000..3da256fe38
--- /dev/null
+++ b/project_forecast_line/i18n/fr.po
@@ -0,0 +1,503 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# 	* project_forecast_line
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 14.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2022-08-09 11:54+0000\n"
+"PO-Revision-Date: 2021-09-10 14:59+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Allow to see forecast dates on quotations"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_account_analytic_line
+msgid "Analytic Line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_res_company
+msgid "Companies"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__company_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__company_id
+msgid "Company"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_res_config_settings
+msgid "Config Settings"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_config
+msgid "Configuration"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__confirmed
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__confirmed
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Confirmed"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__consolidated_forecast
+msgid "Consolidated Forecast"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost
+msgid "Cost"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__cost
+msgid ""
+"Cost, in company currency. Cost is positive for things which add forecast, "
+"such as employees and negative for things which consume forecast such as "
+"holidays, sales, or tasks. "
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__currency_id
+msgid "Currency"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_end
+msgid "Date End"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_from
+msgid "Date From"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_start
+msgid "Date Start"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_to
+msgid "Date To"
+msgstr ""
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Date from"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__date_from
+msgid "Date of the period start for this line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__day
+msgid "Day"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_end
+msgid "Default Forecast Date End"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_start
+msgid "Default Forecast Date Start"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__description
+msgid "Description"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__display_name
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__display_name
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__display_name
+msgid "Display Name"
+msgstr "Nom affiché"
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_employee
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__employee_id
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Employee"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_forecast_role_id
+msgid "Employee Forecast Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_consumption_ids
+msgid "Employee Resource Consumption"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id
+msgid "Employee Resource Forecast Line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_employee_forecast_role
+msgid "Employee forecast role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_forecast_role
+msgid "Employee role for task matching"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines
+#: model:ir.model,name:project_forecast_line.model_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_hours
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__forecast
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__forecast
+#: model:ir.ui.menu,name:project_forecast_line.forecast_menu_root
+#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_line_consolidated
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Forecast"
+msgstr "Plan de charge"
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines_consolidated
+msgid "Forecast (Consolidated)"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__forecast_hours
+msgid ""
+"Forecast (in hours). Forecast is positive for resources which add forecast, "
+"such as employees, and negative for things which consume forecast, such as "
+"holidays, sales, or tasks."
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_end
+msgid "Forecast Date End"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_start
+msgid "Forecast Date Start"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_granularity
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_granularity
+msgid "Forecast Line Granularity"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_horizon
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_horizon
+msgid "Forecast Line Horizon"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_project_stage__forecast_line_type
+msgid "Forecast Line Type"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__group_forecast_line_on_quotation
+msgid "Forecast Line on Quotations"
+msgstr ""
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Forecast Management"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_product_product__forecast_role_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_product_template__forecast_role_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_role_id
+msgid "Forecast Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_roles
+#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_role
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_employee_form
+msgid "Forecast Roles"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.server,name:project_forecast_line.cron_forecast_lines_ir_actions_server
+#: model:ir.cron,cron_name:project_forecast_line.cron_forecast_lines
+#: model:ir.cron,name:project_forecast_line.cron_forecast_lines
+msgid "Forecast recomputation"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_role_id
+msgid "Forecast role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Group By"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__id
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__id
+msgid "ID"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_job
+msgid "Job Position"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line____last_update
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role____last_update
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role____last_update
+msgid "Last Modified on"
+msgstr "Dernière modification le"
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_date
+#, fuzzy
+msgid "Last Updated on"
+msgstr "Dernière modification le"
+
+#. module: project_forecast_line
+#: code:addons/project_forecast_line/models/hr_leave.py:0
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__hr_leave_id
+#, python-format
+msgid "Leave"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__main_role_id
+msgid "Main Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:res.groups,name:project_forecast_line.group_forecast_line_on_quotation
+msgid "Manage Forecast Dates on Quotations"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_model
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Model"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__month
+msgid "Month"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__name
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__name
+msgid "Name"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_horizon
+#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_horizon
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Number of month for the forecast planning"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_granularity
+#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_granularity
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Periodicity of the forecast that will be generated"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_end
+msgid "Planned end date"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_start
+msgid "Planned start date"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_product_template
+msgid "Product Template"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_project_project
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__project_id
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Project"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_project_project_stage
+msgid "Project Stage"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__rate
+msgid "Rate"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_id
+msgid "Record ID"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_resource_calendar_leaves
+msgid "Resource Time Off Detail"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__role_ids
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__role_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_job__role_id
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_id
+msgid "Sale"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_line_id
+#, fuzzy
+msgid "Sale line"
+msgstr "Bon de commande"
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_sale_order
+msgid "Sales Order"
+msgstr "Bon de commande"
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_sale_order_line
+#, fuzzy
+msgid "Sales Order Line"
+msgstr "Bon de commande"
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence
+msgid "Sequence"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.forecast_config_settings_action
+#: model:ir.ui.menu,name:project_forecast_line.forecast_config_settings_menu_action
+msgid "Settings"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_project_task
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__task_id
+msgid "Task"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_leave
+msgid "Time Off"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__type
+msgid "Type"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__week
+msgid "Week"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id
+msgid ""
+"technical field giving the name of the resource (model=hr.employee.forecast."
+"role) line for that employee and that period"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_project_project_stage__forecast_line_type
+msgid "type of forecast lines created by the tasks of projects in that stage"
+msgstr ""
+
+#~ msgid "Indicate if a signed contract has been received for this SO"
+#~ msgstr "Indique si un contrat signé a été reçu pour cette commande"
+
+#~ msgid "Log reception of signed contract"
+#~ msgstr "Enregistrer la réception d'un contrat signé"
+
+#~ msgid "Missing contract"
+#~ msgstr "Contrat manquant"
+
+#~ msgid "Record when the SO has (most recently) been marked as signed "
+#~ msgstr ""
+#~ "Enregistre quand la commande a été marquée comme signée (le plus "
+#~ "récemment)"
+
+#~ msgid "Signed Date"
+#~ msgstr "Date de signature"
+
+#~ msgid "Signed Status"
+#~ msgstr "Statut signature"
+
+#~ msgid ""
+#~ "You are not allowed to change the signed status of Sales Orders.\n"
+#~ "Only members of the 'Log reception of signed contract' group can."
+#~ msgstr ""
+#~ "Vous n'êtes pas autorisé à changer le statut de signature des commandes "
+#~ "de vente.\n"
+#~ "Seuls les membres du goupe 'Enregistrer la réception d'un contrat signé' "
+#~ "le peuvent."
diff --git a/project_forecast_line/i18n/project_forecast_line.pot b/project_forecast_line/i18n/project_forecast_line.pot
new file mode 100644
index 0000000000..3f97478e10
--- /dev/null
+++ b/project_forecast_line/i18n/project_forecast_line.pot
@@ -0,0 +1,470 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# 	* project_forecast_line
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 15.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2022-08-09 11:54+0000\n"
+"PO-Revision-Date: 2022-08-09 11:54+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Allow to see forecast dates on quotations"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_account_analytic_line
+msgid "Analytic Line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_res_company
+msgid "Companies"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__company_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__company_id
+msgid "Company"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_res_config_settings
+msgid "Config Settings"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_config
+msgid "Configuration"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__confirmed
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__confirmed
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Confirmed"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__consolidated_forecast
+msgid "Consolidated Forecast"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost
+msgid "Cost"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__cost
+msgid ""
+"Cost, in company currency. Cost is positive for things which add forecast, "
+"such as employees and negative for things which consume forecast such as "
+"holidays, sales, or tasks. "
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__create_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__create_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__currency_id
+msgid "Currency"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_end
+msgid "Date End"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_from
+msgid "Date From"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__date_start
+msgid "Date Start"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__date_to
+msgid "Date To"
+msgstr ""
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Date from"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__date_from
+msgid "Date of the period start for this line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__day
+msgid "Day"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_end
+msgid "Default Forecast Date End"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order__default_forecast_date_start
+msgid "Default Forecast Date Start"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__description
+msgid "Description"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__display_name
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__display_name
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_employee
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__employee_id
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Employee"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_forecast_role_id
+msgid "Employee Forecast Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_consumption_ids
+msgid "Employee Resource Consumption"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id
+msgid "Employee Resource Forecast Line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_employee_forecast_role
+msgid "Employee forecast role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_forecast_role
+msgid "Employee role for task matching"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines
+#: model:ir.model,name:project_forecast_line.model_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_hours
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__forecast_line__type__forecast
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__project_project_stage__forecast_line_type__forecast
+#: model:ir.ui.menu,name:project_forecast_line.forecast_menu_root
+#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_line_consolidated
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Forecast"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines_consolidated
+msgid "Forecast (Consolidated)"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__forecast_hours
+msgid ""
+"Forecast (in hours). Forecast is positive for resources which add forecast, "
+"such as employees, and negative for things which consume forecast, such as "
+"holidays, sales, or tasks."
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_end
+msgid "Forecast Date End"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_sale_order_line__forecast_date_start
+msgid "Forecast Date Start"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_granularity
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_granularity
+msgid "Forecast Line Granularity"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_line_horizon
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_line_horizon
+msgid "Forecast Line Horizon"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_project_stage__forecast_line_type
+msgid "Forecast Line Type"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__group_forecast_line_on_quotation
+msgid "Forecast Line on Quotations"
+msgstr ""
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Forecast Management"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_product_product__forecast_role_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_product_template__forecast_role_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_role_id
+msgid "Forecast Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.action_forecast_roles
+#: model:ir.ui.menu,name:project_forecast_line.menu_forecast_role
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_employee_form
+msgid "Forecast Roles"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.server,name:project_forecast_line.cron_forecast_lines_ir_actions_server
+#: model:ir.cron,cron_name:project_forecast_line.cron_forecast_lines
+#: model:ir.cron,name:project_forecast_line.cron_forecast_lines
+msgid "Forecast recomputation"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__forecast_role_id
+msgid "Forecast role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Group By"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__id
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__id
+msgid "ID"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_job
+msgid "Job Position"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line____last_update
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role____last_update
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role____last_update
+msgid "Last Modified on"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_uid
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__write_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__write_date
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: project_forecast_line
+#: code:addons/project_forecast_line/models/hr_leave.py:0
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__hr_leave_id
+#, python-format
+msgid "Leave"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__main_role_id
+msgid "Main Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:res.groups,name:project_forecast_line.group_forecast_line_on_quotation
+msgid "Manage Forecast Dates on Quotations"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_model
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Model"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__month
+msgid "Month"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__name
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_role__name
+msgid "Name"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_horizon
+#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_horizon
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Number of month for the forecast planning"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_line_granularity
+#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_line_granularity
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Periodicity of the forecast that will be generated"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_end
+msgid "Planned end date"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_project_task__forecast_date_planned_start
+msgid "Planned start date"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_product_template
+msgid "Product Template"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_project_project
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__project_id
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Project"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_project_project_stage
+msgid "Project Stage"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__rate
+msgid "Rate"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__res_id
+msgid "Record ID"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_resource_calendar_leaves
+msgid "Resource Time Off Detail"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee__role_ids
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__role_id
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_job__role_id
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.view_forecast_line_search
+msgid "Role"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_id
+msgid "Sale"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__sale_line_id
+msgid "Sale line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_sale_order
+msgid "Sales Order"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_sale_order_line
+msgid "Sales Order Line"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence
+msgid "Sequence"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.actions.act_window,name:project_forecast_line.forecast_config_settings_action
+#: model:ir.ui.menu,name:project_forecast_line.forecast_config_settings_menu_action
+msgid "Settings"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_project_task
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__task_id
+msgid "Task"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model,name:project_forecast_line.model_hr_leave
+msgid "Time Off"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__type
+msgid "Type"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_line_granularity__week
+msgid "Week"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_forecast_line__employee_resource_forecast_line_id
+msgid ""
+"technical field giving the name of the resource "
+"(model=hr.employee.forecast.role) line for that employee and that period"
+msgstr ""
+
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_project_project_stage__forecast_line_type
+msgid "type of forecast lines created by the tasks of projects in that stage"
+msgstr ""
diff --git a/project_forecast_line/models/__init__.py b/project_forecast_line/models/__init__.py
new file mode 100644
index 0000000000..9131303b85
--- /dev/null
+++ b/project_forecast_line/models/__init__.py
@@ -0,0 +1,14 @@
+from . import forecast_line
+from . import forecast_role
+from . import hr_employee
+from . import product_template
+from . import sale_order
+from . import sale_order_line
+from . import res_company
+from . import hr_leave
+from . import project_task
+from . import account_analytic_line
+from . import res_config_settings
+from . import resource_calendar_leaves
+from . import project_project_stage
+from . import project_project
diff --git a/project_forecast_line/models/account_analytic_line.py b/project_forecast_line/models/account_analytic_line.py
new file mode 100644
index 0000000000..d90a5be38f
--- /dev/null
+++ b/project_forecast_line/models/account_analytic_line.py
@@ -0,0 +1,18 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import api, models
+
+
+class AccountAnalyticLine(models.Model):
+    _inherit = "account.analytic.line"
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        recs = super().create(vals_list)
+        recs.mapped("task_id")._update_forecast_lines()
+        return recs
+
+    def write(self, values):
+        res = super().write(values)
+        self.mapped("task_id")._update_forecast_lines()
+        return res
diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
new file mode 100644
index 0000000000..50d54f2da8
--- /dev/null
+++ b/project_forecast_line/models/forecast_line.py
@@ -0,0 +1,322 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+import logging
+from datetime import datetime, time
+
+import pytz
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+from odoo.tools import date_utils, mute_logger
+
+_logger = logging.getLogger(__name__)
+
+
+class ForecastLine(models.Model):
+    """
+    we generate 1 forecast line per period defined on the current company (day, week, month).
+    """
+
+    _name = "forecast.line"
+    _order = "date_from, employee_id, project_id"
+    _description = "Forecast"
+
+    name = fields.Char(required=True)
+    date_from = fields.Date(
+        required=True, help="Date of the period start for this line"
+    )
+    date_to = fields.Date(required=True)
+    forecast_role_id = fields.Many2one(
+        "forecast.role", string="Forecast role", required=True, index=True
+    )
+    employee_id = fields.Many2one("hr.employee", string="Employee")
+    employee_forecast_role_id = fields.Many2one(
+        "hr.employee.forecast.role", string="Employee Forecast Role"
+    )
+    project_id = fields.Many2one("project.project", index=True, string="Project")
+    task_id = fields.Many2one("project.task", index=True, string="Task")
+    sale_id = fields.Many2one(
+        "sale.order",
+        related="sale_line_id.order_id",
+        store=True,
+        index=True,
+        string="Sale",
+    )
+    sale_line_id = fields.Many2one("sale.order.line", index=True, string="Sale line")
+    hr_leave_id = fields.Many2one("hr.leave", index=True, string="Leave")
+    forecast_hours = fields.Float(
+        "Forecast",
+        help="Forecast (in hours). Forecast is positive for resources which add forecast, "
+        "such as employees, and negative for things which consume forecast, such as "
+        "holidays, sales, or tasks.",
+    )
+    cost = fields.Monetary(
+        help="Cost, in company currency. Cost is positive for things which add forecast, "
+        "such as employees and negative for things which consume forecast such as "
+        "holidays, sales, or tasks. ",
+    )
+    consolidated_forecast = fields.Float(
+        digits=(12, 5),
+        store=True,
+        compute="_compute_consolidated_forecast",
+    )
+
+    currency_id = fields.Many2one(related="company_id.currency_id", store=True)
+    company_id = fields.Many2one(
+        "res.company", required=True, default=lambda s: s.env.company
+    )
+    type = fields.Selection(
+        [("forecast", "Forecast"), ("confirmed", "Confirmed")],
+        required=True,
+        default="forecast",
+    )
+    res_model = fields.Char(string="Model", index=True)
+    res_id = fields.Integer(string="Record ID", index=True)
+    employee_resource_forecast_line_id = fields.Many2one(
+        "forecast.line",
+        store=True,
+        index=True,
+        compute="_compute_employee_forecast_line_id",
+        ondelete="set null",
+        help="technical field giving the name of the resource "
+        "(model=hr.employee.forecast.role) line for that employee and that period",
+    )
+    employee_resource_consumption_ids = fields.One2many(
+        "forecast.line", "employee_resource_forecast_line_id"
+    )
+
+    @api.depends("employee_id", "date_from", "type", "res_model")
+    def _compute_employee_forecast_line_id(self):
+        employees = self.mapped("employee_id")
+        date_froms = self.mapped("date_from")
+        date_tos = self.mapped("date_to")
+        if employees:
+            lines = self.search(
+                [
+                    ("employee_id", "in", employees.ids),
+                    ("res_model", "=", "hr.employee.forecast.role"),
+                    ("date_from", ">=", min(date_froms)),
+                    ("date_to", "<=", max(date_tos)),
+                    ("type", "=", "confirmed"),
+                ]
+            )
+        else:
+            lines = self.env["forecast.line"]
+        capacities = {}
+        for line in lines:
+            capacities[(line.employee_id.id, line.date_from)] = line.id
+        for rec in self:
+            if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role":
+                rec.employee_resource_forecast_line_id = capacities.get(
+                    (rec.employee_id.id, rec.date_from), False
+                )
+            else:
+                rec.employee_resource_forecast_line_id = False
+
+    def _convert_forecast(self, data):
+        """
+        Converts consolidated forecast from hours to days
+        """
+        self.ensure_one()
+        to_convert_uom = self.env.ref("uom.product_uom_day")
+        project_time_mode_id = self.company_id.project_time_mode_id
+        if self.res_model != "hr.employee.forecast.role":
+            return -project_time_mode_id._compute_quantity(
+                self.forecast_hours, to_convert_uom, round=False
+            )
+        else:
+            forecast_hours = self.forecast_hours + data.get(self.id, 0)
+            return project_time_mode_id._compute_quantity(
+                forecast_hours, to_convert_uom, round=False
+            )
+
+    @api.depends("employee_resource_consumption_ids.forecast_hours", "forecast_hours")
+    def _compute_consolidated_forecast(self):
+        data = {}
+        for d in self.env["forecast.line"].read_group(
+            [("employee_resource_forecast_line_id", "in", self.ids)],
+            fields=["forecast_hours"],
+            groupby=["employee_resource_forecast_line_id"],
+        ):
+            data[d["employee_resource_forecast_line_id"][0]] = d["forecast_hours"]
+        for rec in self:
+            rec.consolidated_forecast = rec._convert_forecast(data)
+
+    def prepare_forecast_lines(
+        self,
+        name,
+        date_from,
+        date_to,
+        ttype,
+        forecast_hours,
+        unit_cost,
+        res_model="",
+        res_id=0,
+        **kwargs
+    ):
+        common_value_dict = {
+            "company_id": self.env.company.id,
+            "name": name,
+            "type": ttype,
+            "forecast_role_id": kwargs.get("forecast_role_id", False),
+            "employee_id": kwargs.get("employee_id", False),
+            "project_id": kwargs.get("project_id", False),
+            "task_id": kwargs.get("task_id", False),
+            "sale_line_id": kwargs.get("sale_line_id", False),
+            "hr_leave_id": kwargs.get("hr_leave_id", False),
+            "employee_forecast_role_id": kwargs.get("employee_forecast_role_id", False),
+            "res_model": res_model,
+            "res_id": res_id,
+        }
+        forecast_line_vals = []
+        if common_value_dict["employee_id"]:
+            resource = (
+                self.env["hr.employee"]
+                .browse(common_value_dict["employee_id"])
+                .resource_id
+            )
+            calendar = resource.calendar_id
+        else:
+            resource = self.env["resource.resource"]
+            calendar = self.env.company.resource_calendar_id
+        for updates in self._split_per_period(
+            date_from, date_to, forecast_hours, unit_cost, resource, calendar
+        ):
+            values = common_value_dict.copy()
+            values.update(updates)
+            forecast_line_vals.append(values)
+        return forecast_line_vals
+
+    def _company_horizon_end(self):
+        company = self.env.company
+        today = fields.Date.context_today(self)
+        horizon_end = today + relativedelta(months=company.forecast_line_horizon)
+        return horizon_end
+
+    def _split_per_period(
+        self, date_from, date_to, forecast_hours, unit_cost, resource, calendar
+    ):
+        company = self.env.company
+        today = fields.Date.context_today(self)
+        granularity = company.forecast_line_granularity
+        delta = date_utils.get_timedelta(1, granularity)
+        horizon_end = self._company_horizon_end()
+        # the date_to passed as argument is "included". We want to be able to
+        # reason with this date "excluded" when doing substractions to compute
+        # a number of days -> add 1d
+        date_to += relativedelta(days=1)
+        horiz_date_from = max(date_from, today)
+        horiz_date_to = min(date_to, horizon_end)
+        curr_date = date_utils.start_of(horiz_date_from, granularity)
+        if horiz_date_to <= horiz_date_from:
+            return
+        whole_period_forecast = self._number_of_hours(
+            horiz_date_from, horiz_date_to, resource, calendar
+        )
+        if whole_period_forecast == 0:
+            # the resource if completely off during the period -> we cannot
+            # plan the forecast in the period. We put the whole forecast on the
+            # day after the period.
+            # TODO future improvement: dump this on the
+            # first day when the employee is not on holiday
+            _logger.warning(
+                "resource %s has 0 forecast on period %s -> %s",
+                resource,
+                horiz_date_from,
+                horiz_date_to,
+            )
+            yield {
+                "date_from": horiz_date_to,
+                "date_to": horiz_date_to + delta - relativedelta(days=1),
+                "forecast_hours": forecast_hours,
+                "cost": forecast_hours * unit_cost,
+            }
+            return
+        daily_forecast = forecast_hours / whole_period_forecast
+        if daily_forecast == 0:
+            return
+        while curr_date < horiz_date_to:
+            next_date = curr_date + delta
+            # XXX fix periods which are not entirely in the horizon
+            # (min max trick on the numerator of the division)
+            period_forecast = self._number_of_hours(
+                max(curr_date, date_from),
+                min(next_date, date_to),
+                resource,
+                calendar,
+            )
+            if period_forecast == 0:
+                # don"t create forecast lines with a forecast of 0
+                curr_date = next_date
+                continue
+            period_forecast *= daily_forecast
+            period_cost = period_forecast * unit_cost
+            updates = {
+                "date_from": curr_date,
+                "date_to": next_date - relativedelta(days=1),
+                "forecast_hours": period_forecast,
+                "cost": period_cost,
+            }
+            yield updates
+            curr_date = next_date
+
+    @api.model
+    def _cron_recompute_all(self, force_company_id=None):
+        today = fields.Date.context_today(self)
+        ForecastLine = self.env["forecast.line"].sudo()
+        if force_company_id:
+            companies = self.env["res.company"].browse(force_company_id)
+        else:
+            companies = self.env["res.company"].search([])
+        for company in companies:
+            ForecastLine = ForecastLine.with_company(company)
+            limit_date = date_utils.start_of(today, company.forecast_line_granularity)
+            stale_forecast_lines = ForecastLine.search(
+                [
+                    ("date_from", "<", limit_date),
+                    ("company_id", "=", company.id),
+                ]
+            )
+            stale_forecast_lines.unlink()
+
+        # always start with forecast role to ensure we can compute the
+        # employee_resource_forecast_line_id field
+        self.env["hr.employee.forecast.role"]._recompute_forecast_lines(
+            force_company_id=force_company_id
+        )
+        self.env["sale.order.line"]._recompute_forecast_lines(
+            force_company_id=force_company_id
+        )
+        self.env["hr.leave"]._recompute_forecast_lines(
+            force_company_id=force_company_id
+        )
+        self.env["project.task"]._recompute_forecast_lines(
+            force_company_id=force_company_id
+        )
+        # fix weird issue where the employee_resource_forecast_line_id seems to
+        # not be always computed
+        ForecastLine.search([])._compute_employee_forecast_line_id()
+
+    @api.model
+    def convert_days_to_hours(self, days):
+        uom_day = self.env.ref("uom.product_uom_day")
+        uom_hour = self.env.ref("uom.product_uom_hour")
+        return uom_day._compute_quantity(days, uom_hour)
+
+    @api.model
+    def _number_of_hours(self, date_from, date_to, resource, calendar):
+        tzinfo = pytz.timezone(calendar.tz)
+        start_dt = tzinfo.localize(datetime.combine(date_from, time(0)))
+        end_dt = tzinfo.localize(datetime.combine(date_to, time(0)))
+        intervals = calendar._work_intervals_batch(
+            start_dt, end_dt, resources=resource
+        )[resource.id]
+        nb_hours = sum(
+            (stop - start).total_seconds() / 3600 for start, stop, meta in intervals
+        )
+        return nb_hours
+
+    def unlink(self):
+        # we routinely unlink forecast lines, let"s not fill the logs with this
+        with mute_logger("odoo.models.unlink"):
+            return super().unlink()
diff --git a/project_forecast_line/models/forecast_role.py b/project_forecast_line/models/forecast_role.py
new file mode 100644
index 0000000000..079b35a658
--- /dev/null
+++ b/project_forecast_line/models/forecast_role.py
@@ -0,0 +1,11 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ForecastRole(models.Model):
+    _name = "forecast.role"
+    _description = "Employee role for task matching"
+
+    name = fields.Char(required=True)
+    description = fields.Text()
diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py
new file mode 100644
index 0000000000..2c854eff82
--- /dev/null
+++ b/project_forecast_line/models/hr_employee.py
@@ -0,0 +1,148 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models
+
+
+class HrJob(models.Model):
+    _inherit = "hr.job"
+
+    role_id = fields.Many2one("forecast.role")
+
+
+class HrEmployee(models.Model):
+    _inherit = "hr.employee"
+
+    role_ids = fields.One2many("hr.employee.forecast.role", "employee_id")
+    main_role_id = fields.Many2one("forecast.role", compute="_compute_main_role_id")
+
+    def _compute_main_role_id(self):
+        # can"t store as it depends on current date
+        today = fields.Date.context_today(self)
+        for rec in self:
+            rec.main_role_id = rec.role_ids.filtered(
+                lambda r: r.date_start <= today
+                and not r.date_end
+                or r.date_end >= today
+            )[:1].role_id
+
+    def write(self, values):
+        values = self._check_job_role(values)
+        return super().write(values)
+
+    @api.model_create_multi
+    @api.returns("self", lambda value: value.id)
+    def create(self, values):
+        values = [self._check_job_role(val) for val in values]
+        return super().create(values)
+
+    def _check_job_role(self, values):
+        """helper method
+        ensures that you get a role when you set a job with a role"""
+        new_job_id = values.get("job_id")
+        if new_job_id:
+            job = self.env["hr.job"].browse(new_job_id)
+            if job.role_id and "role_ids" not in values:
+                values = values.copy()
+                values["role_ids"] = [
+                    fields.Command.clear(),
+                    fields.Command.create({"role_id": job.role_id.id}),
+                ]
+        return values
+
+
+class HrEmployeeForecastRole(models.Model):
+    _name = "hr.employee.forecast.role"
+    _description = "Employee forecast role"
+    _order = "employee_id, date_start, sequence, rate DESC, id"
+
+    employee_id = fields.Many2one("hr.employee", required=True, ondelete="cascade")
+    role_id = fields.Many2one("forecast.role", required=True)
+    date_start = fields.Date(required=True, default=fields.Date.today)
+    date_end = fields.Date()
+    rate = fields.Integer(default=100)
+    sequence = fields.Integer()
+    company_id = fields.Many2one(related="employee_id.company_id", store=True)
+    # TODO:
+    # ensure sum of rate = 100
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        recs = super().create(vals_list)
+        recs._update_forecast_lines()
+        return recs
+
+    def write(self, values):
+        res = super().write(values)
+        self._update_forecast_lines()
+        return res
+
+    def _update_forecast_lines(self):
+        today = fields.Date.context_today(self)
+        ForecastLine = self.env["forecast.line"].sudo()
+        if not self:
+            return ForecastLine
+        leaves = self.env["hr.leave"].search(
+            [
+                ("employee_id", "in", self.mapped("employee_id").ids),
+                ("state", "!=", "cancel"),
+                ("date_to", ">=", min(self.mapped("date_start"))),
+            ]
+        )
+        leaves._update_forecast_lines()
+        forecast_vals = []
+        ForecastLine.search(
+            [("res_id", "in", self.ids), ("res_model", "=", self._name)]
+        ).unlink()
+        horizon_end = ForecastLine._company_horizon_end()
+        for rec in self:
+            if rec.date_end:
+                date_end = rec.date_end
+            else:
+                date_end = horizon_end - relativedelta(days=1)
+            date_start = max(rec.date_start, today)
+            resource = rec.employee_id.resource_id
+            calendar = resource.calendar_id
+
+            forecast = ForecastLine._number_of_hours(
+                date_start,
+                date_end + relativedelta(days=1),
+                resource,
+                calendar,
+            )
+            forecast_vals += ForecastLine.prepare_forecast_lines(
+                name="Employee %s as %s (%d%%)"
+                % (rec.employee_id.name, rec.role_id.name, rec.rate),
+                date_from=rec.date_start,
+                date_to=date_end,
+                forecast_hours=forecast * rec.rate / 100.0,
+                unit_cost=rec.employee_id.timesheet_cost,  # XXX to check
+                ttype="confirmed",
+                forecast_role_id=rec.role_id.id,
+                employee_id=rec.employee_id.id,
+                employee_forecast_role_id=rec.id,
+                res_model=self._name,
+                res_id=rec.id,
+            )
+
+        return ForecastLine.create(forecast_vals)
+
+    @api.model
+    def _recompute_forecast_lines(self, force_company_id=None):
+        today = fields.Date.context_today(self)
+        if force_company_id:
+            companies = self.env["res.company"].browse(force_company_id)
+        else:
+            companies = self.env["res.company"].search([])
+        for company in companies:
+            to_update = self.with_company(company).search(
+                [
+                    "|",
+                    ("date_end", "=", False),
+                    ("date_end", ">=", today),
+                    ("company_id", "=", company.id),
+                ]
+            )
+            to_update._update_forecast_lines()
diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py
new file mode 100644
index 0000000000..7c7c7c18d1
--- /dev/null
+++ b/project_forecast_line/models/hr_leave.py
@@ -0,0 +1,78 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+import logging
+
+from odoo import _, api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class HrLeave(models.Model):
+    _inherit = "hr.leave"
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        leaves = super().create(vals_list)
+        leaves._update_forecast_lines()
+        return leaves
+
+    def write(self, values):
+        res = super().write(values)
+        self._update_forecast_lines()
+        return res
+
+    def _update_forecast_lines(self):
+        forecast_vals = []
+        ForecastLine = self.env["forecast.line"].sudo()
+        # XXX try to be smarter and only unlink those needing unlinking, update the others
+        ForecastLine.search(
+            [("res_id", "in", self.ids), ("res_model", "=", self._name)]
+        ).unlink()
+        leaves = self.filtered_domain([("state", "!=", "refuse")])
+        for leave in leaves:
+            if not leave.employee_id.main_role_id:
+                _logger.warning(
+                    "No forecast role for employee %s (%s)",
+                    leave.employee_id.name,
+                    leave.employee_id,
+                )
+                continue
+            if leave.state == "validate":
+                # will be handled by the resource.calendar.leaves
+                continue
+            else:
+                forecast_type = "forecast"
+            forecast_vals += ForecastLine.prepare_forecast_lines(
+                name=_("Leave"),
+                date_from=leave.date_from.date(),
+                date_to=leave.date_to.date(),
+                ttype=forecast_type,
+                forecast_hours=ForecastLine.convert_days_to_hours(
+                    -1 * leave.number_of_days
+                ),
+                unit_cost=leave.employee_id.timesheet_cost,
+                forecast_role_id=leave.employee_id.main_role_id.id,
+                hr_leave_id=leave.id,
+                res_model=self._name,
+                res_id=leave.id,
+            )
+        return ForecastLine.create(forecast_vals)
+
+    @api.model
+    def _recompute_forecast_lines(self, force_company_id=None):
+        today = fields.Date.context_today(self)
+        if force_company_id:
+            companies = self.env["res.company"].browse(force_company_id)
+        else:
+            companies = self.env["res.company"].search([])
+        for company in companies:
+            to_update = self.with_company(company).search(
+                [
+                    ("date_to", ">=", today),
+                    ("employee_company_id", "=", company.id),
+                ]
+            )
+            to_update._update_forecast_lines()
+
+
+# XXX: leave request should create forcast negative forecast?
diff --git a/project_forecast_line/models/product_template.py b/project_forecast_line/models/product_template.py
new file mode 100644
index 0000000000..6fd7faa8d8
--- /dev/null
+++ b/project_forecast_line/models/product_template.py
@@ -0,0 +1,9 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ProductTemplate(models.Model):
+    _inherit = "product.template"
+
+    forecast_role_id = fields.Many2one("forecast.role")
diff --git a/project_forecast_line/models/project_project.py b/project_forecast_line/models/project_project.py
new file mode 100644
index 0000000000..0daf904664
--- /dev/null
+++ b/project_forecast_line/models/project_project.py
@@ -0,0 +1,18 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import models
+
+
+class ProjectProject(models.Model):
+    _inherit = "project.project"
+
+    def _update_forecast_lines_trigger_fields(self):
+        return ["stage_id"]
+
+    def write(self, values):
+        res = super().write(values)
+        written_fields = list(values.keys())
+        trigger_fields = self._update_forecast_lines_trigger_fields()
+        if any(field in written_fields for field in trigger_fields):
+            self.task_ids._update_forecast_lines()
+        return res
diff --git a/project_forecast_line/models/project_project_stage.py b/project_forecast_line/models/project_project_stage.py
new file mode 100644
index 0000000000..02eb6d63dc
--- /dev/null
+++ b/project_forecast_line/models/project_project_stage.py
@@ -0,0 +1,21 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ProjectProjectStage(models.Model):
+    _inherit = "project.project.stage"
+
+    forecast_line_type = fields.Selection(
+        [("forecast", "Forecast"), ("confirmed", "Confirmed")],
+        help="type of forecast lines created by the tasks of projects in that stage",
+    )
+
+    def write(self, values):
+        res = super().write(values)
+        if "forecast_line_type" in values:
+            projects = self.env["project.project"].search(
+                [("stage_id", "in", self.ids)]
+            )
+            projects.mapped("task_ids")._update_forecast_lines()
+        return res
diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py
new file mode 100644
index 0000000000..7e4df82a15
--- /dev/null
+++ b/project_forecast_line/models/project_task.py
@@ -0,0 +1,155 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class ProjectTask(models.Model):
+    _inherit = "project.task"
+
+    forecast_role_id = fields.Many2one("forecast.role")
+    forecast_date_planned_start = fields.Date("Planned start date")
+    forecast_date_planned_end = fields.Date("Planned end date")
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        # compatibility with fields from project_enterprise
+        for vals in vals_list:
+            if "planned_date_begin" in vals:
+                vals["forecast_date_planned_start"] = vals["planned_date_begin"]
+            if "planned_date_end" in vals:
+                vals["forecast_date_planned_end"] = vals["planned_date_end"]
+        tasks = super().create(vals_list)
+        tasks._update_forecast_lines()
+        return tasks
+
+    def _update_forecast_lines_trigger_fields(self):
+        return [
+            "sale_order_line_id",
+            "forecast_role_id",
+            "forecast_date_planned_start",
+            "forecast_date_planned_end",
+            "remaining_hours",
+            "name",
+            "planned_time",
+            "user_ids",
+        ]
+
+    def write(self, values):
+        # compatibility with fields from project_enterprise
+        if "planned_date_begin" in values:
+            values["forecast_date_planned_start"] = values["planned_date_begin"]
+        if "planned_date_end" in values:
+            values["forecast_date_planned_end"] = values["planned_date_end"]
+        res = super().write(values)
+        written_fields = list(values.keys())
+        trigger_fields = self._update_forecast_lines_trigger_fields()
+        if any(field in written_fields for field in trigger_fields):
+            self._update_forecast_lines()
+        return res
+
+    @api.onchange("user_ids")
+    def onchange_user_ids(self):
+        for task in self:
+            if not task.user_ids:
+                continue
+            if task.forecast_role_id:
+                continue
+            employees = task.mapped("user_ids.employee_id")
+            for employee in employees:
+                if employee.main_role_id:
+                    task.forecast_role_id = employee.main_role_id
+                    break
+
+    def _update_forecast_lines(self):
+        today = fields.Date.context_today(self)
+        forecast_vals = []
+        ForecastLine = self.env["forecast.line"].sudo()
+        # XXX try to be smarter and only unlink those needing unlinking, update the others
+        ForecastLine.search(
+            [("res_id", "in", self.ids), ("res_model", "=", self._name)]
+        ).unlink()
+        for task in self:
+            if not task.forecast_role_id:
+                _logger.info("skip task %s: no forecast role", task)
+                continue
+            elif task.project_id.stage_id:
+                forecast_type = task.project_id.stage_id.forecast_line_type
+                if not forecast_type:
+                    _logger.info("skip task %s: no forecast for project state", task)
+                    continue  # closed / cancelled stage
+            elif task.sale_line_id:
+                sale_state = task.sale_line_id.state
+                if sale_state == "cancel":
+                    _logger.info("skip task %s: cancelled sale", task)
+                elif sale_state == "sale":
+                    forecast_type = "confirmed"
+                else:
+                    # no forecast line for cancelled sales
+                    #
+                    # TODO have forecast quantity if the sale is in Draft and we
+                    # are not generating forecast lines from SO
+                    _logger.info("skip task %s: draft sale")
+                    continue
+            if (
+                not task.forecast_date_planned_start
+                or not task.forecast_date_planned_end
+            ):
+                _logger.info("skip task %s: no planned dates", task)
+                continue
+            if not task.remaining_hours:
+                _logger.info("skip task %s: no remaining hours", task)
+                continue
+            if task.remaining_hours < 0:
+                _logger.info("skip task %s: negative remaining hours", task)
+                continue
+            date_start = max(today, task.forecast_date_planned_start)
+            date_end = max(today, task.forecast_date_planned_end)
+            employee_ids = task.mapped("user_ids.employee_id").ids
+            if not employee_ids:
+                employee_ids = [False]
+            _logger.debug(
+                "compute forecast for task %s: %s to %s %sh",
+                task,
+                date_start,
+                date_end,
+                task.remaining_hours,
+            )
+            forecast_hours = task.remaining_hours / len(employee_ids)
+            for employee_id in employee_ids:
+                forecast_vals += ForecastLine.prepare_forecast_lines(
+                    name=task.name,
+                    date_from=date_start,
+                    date_to=date_end,
+                    ttype=forecast_type,
+                    forecast_hours=-1 * forecast_hours,
+                    # XXX currency + unit conversion
+                    unit_cost=task.sale_line_id.product_id.standard_price,
+                    forecast_role_id=task.forecast_role_id.id,
+                    sale_line_id=task.sale_line_id.id,
+                    task_id=task.id,
+                    project_id=task.project_id.id,
+                    employee_id=employee_id,
+                    res_model=self._name,
+                    res_id=task.id,
+                )
+        return ForecastLine.create(forecast_vals)
+
+    @api.model
+    def _recompute_forecast_lines(self, force_company_id=None):
+        today = fields.Date.context_today(self)
+        if force_company_id:
+            companies = self.env["res.company"].browse(force_company_id)
+        else:
+            companies = self.env["res.company"].search([])
+        for company in companies:
+            to_update = self.with_company(company).search(
+                [
+                    ("forecast_date_planned_end", ">=", today),
+                    ("company_id", "=", company.id),
+                ]
+            )
+            to_update._update_forecast_lines()
diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py
new file mode 100644
index 0000000000..4d88fee87d
--- /dev/null
+++ b/project_forecast_line/models/res_company.py
@@ -0,0 +1,25 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ResCompany(models.Model):
+    _inherit = "res.company"
+
+    forecast_line_granularity = fields.Selection(
+        [("day", "Day"), ("week", "Week"), ("month", "Month")],
+        default="month",
+        help="Periodicity of the forecast that will be generated",
+    )
+    forecast_line_horizon = fields.Integer(
+        help="Number of month for the forecast planning", default=12
+    )
+
+    def write(self, values):
+        res = super().write(values)
+        if "forecast_line_granularity" in values or "forecast_line_horizon" in values:
+            for company in self:
+                self.env["forecast.line"]._cron_recompute_all(
+                    force_company_id=company.id
+                )
+        return res
diff --git a/project_forecast_line/models/res_config_settings.py b/project_forecast_line/models/res_config_settings.py
new file mode 100644
index 0000000000..4ac43649ec
--- /dev/null
+++ b/project_forecast_line/models/res_config_settings.py
@@ -0,0 +1,19 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+    _inherit = "res.config.settings"
+
+    forecast_line_granularity = fields.Selection(
+        related="company_id.forecast_line_granularity", readonly=False
+    )
+    forecast_line_horizon = fields.Integer(
+        related="company_id.forecast_line_horizon", readonly=False
+    )
+
+    group_forecast_line_on_quotation = fields.Boolean(
+        "Forecast Line on Quotations",
+        implied_group="project_forecast_line.group_forecast_line_on_quotation",
+    )
diff --git a/project_forecast_line/models/resource_calendar_leaves.py b/project_forecast_line/models/resource_calendar_leaves.py
new file mode 100644
index 0000000000..5dfaed8d72
--- /dev/null
+++ b/project_forecast_line/models/resource_calendar_leaves.py
@@ -0,0 +1,32 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from odoo import api, models
+
+
+class ResourceCalendarLeaves(models.Model):
+    _inherit = "resource.calendar.leaves"
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        recs = super().create(vals_list)
+        recs._update_forecast_lines()
+        return recs
+
+    def write(self, values):
+        res = super().write(values)
+        self._update_forecast_lines()
+        return res
+
+    def _update_forecast_lines(self):
+        resources = self.mapped("resource_id")
+        if resources:
+            employees = self.env["hr.employee"].search([("id", "in", resources.ids)])
+        else:
+            employees = self.env["hr.employee"].search(
+                [("company_id", "in", self.mapped("company_id").ids)]
+            )
+        roles = self.env["hr.employee.forecast.role"].search(
+            [("employee_id", "in", employees.ids)]
+        )
+        roles._update_forecast_lines()
diff --git a/project_forecast_line/models/sale_order.py b/project_forecast_line/models/sale_order.py
new file mode 100644
index 0000000000..1aa06a492b
--- /dev/null
+++ b/project_forecast_line/models/sale_order.py
@@ -0,0 +1,32 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class SaleOrder(models.Model):
+    _inherit = "sale.order"
+
+    default_forecast_date_start = fields.Date()
+    default_forecast_date_end = fields.Date()
+
+    def action_cancel(self):
+        res = super().action_cancel()
+        self.filtered(lambda r: r.state == "cancel").mapped(
+            "order_line"
+        )._update_forecast_lines()
+        return res
+
+    def action_confirm(self):
+        res = super().action_confirm()
+        self.filtered(lambda r: r.state == "sale").mapped(
+            "order_line"
+        )._update_forecast_lines()
+        return res
+
+    def write(self, values):
+        res = super().write(values)
+        if self and "project_id" in values:
+            self.env["forecast.line"].sudo().search(
+                [("sale_id", "in", self.ids)]
+            ).write({"project_id": values["project_id"]})
+        return res
diff --git a/project_forecast_line/models/sale_order_line.py b/project_forecast_line/models/sale_order_line.py
new file mode 100644
index 0000000000..cd8d680aa0
--- /dev/null
+++ b/project_forecast_line/models/sale_order_line.py
@@ -0,0 +1,137 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class SaleOrderLine(models.Model):
+    _inherit = "sale.order.line"
+
+    forecast_date_start = fields.Date()
+    forecast_date_end = fields.Date()
+
+    @api.model_create_multi
+    def create(self, vals_list):
+        lines = super().create(vals_list)
+        lines._update_forecast_lines()
+        return lines
+
+    def _update_forecast_lines(self):
+        forecast_vals = []
+        ForecastLine = self.env["forecast.line"].sudo()
+        # XXX try to be smarter and only unlink those needing unlinking, update the others
+        ForecastLine.search(
+            [("res_id", "in", self.ids), ("res_model", "=", self._name)]
+        ).unlink()
+        for line in self:
+            if not line.product_id.forecast_role_id:
+                continue
+            elif line.state in ("cancel", "sale"):
+                # no forecast line for confirmed sales -> this is handled by projects and tasks
+                continue
+            elif not (line.forecast_date_end and line.forecast_date_start):
+                _logger.info(
+                    "sale line with forecast product but no dates -> ignoring %s",
+                    line.id,
+                )
+                continue
+            else:
+                forecast_type = "forecast"
+            uom = line.product_uom
+            quantity_hours = uom._compute_quantity(
+                line.product_uom_qty, self.env.ref("uom.product_uom_hour")
+            )
+            forecast_vals += ForecastLine.prepare_forecast_lines(
+                name=line.name,
+                date_from=line.forecast_date_start,
+                date_to=line.forecast_date_end,
+                ttype=forecast_type,
+                forecast_hours=-1 * quantity_hours,
+                unit_cost=line.product_id.standard_price,  # XXX currency + unit conversion
+                forecast_role_id=line.product_id.forecast_role_id.id,
+                sale_line_id=line.id,
+                project_id=line.project_id.id,
+                res_model="sale.order.line",
+                res_id=line.id,
+            )
+        return ForecastLine.create(forecast_vals)
+
+    @api.model
+    def _recompute_forecast_lines(self, force_company_id=None):
+        today = fields.Date.context_today(self)
+        if force_company_id:
+            companies = self.env["res.company"].browse(force_company_id)
+        else:
+            companies = self.env["res.company"].search([])
+        for company in companies:
+            to_update = self.with_company(company).search(
+                [
+                    ("forecast_date_end", ">=", today),
+                    ("company_id", "=", company.id),
+                ]
+            )
+            to_update._update_forecast_lines()
+
+    def _update_forecast_lines_trigger_fields(self):
+        return [
+            "state",
+            "product_uom_qty",
+            "forecast_date_start",
+            "forecast_date_end",
+            "product_id",
+            "name",
+        ]
+
+    def write(self, values):
+        res = super().write(values)
+        written_fields = list(values.keys())
+        trigger_fields = self._update_forecast_lines_trigger_fields()
+        if any(field in written_fields for field in trigger_fields):
+            self._update_forecast_lines()
+        return res
+
+    @api.onchange("product_id")
+    def product_id_change(self):
+        res = super().product_id_change()
+        for line in self:
+            if not line.product_id.forecast_role_id:
+                line.forecast_date_start = False
+                line.forecast_date_end = False
+            else:
+                if (
+                    not line.forecast_date_start
+                    and line.order_id.default_forecast_date_start
+                ):
+                    line.forecast_date_start = line.order_id.default_forecast_date_start
+                if (
+                    not line.forecast_date_end
+                    and line.order_id.default_forecast_date_end
+                ):
+                    line.forecast_date_end = line.order_id.default_forecast_date_end
+        return res
+
+    def _timesheet_create_task_prepare_values(self, project):
+        values = super()._timesheet_create_task_prepare_values(project)
+        values.update(
+            {
+                "forecast_role_id": self.product_id.forecast_role_id.id,
+                "forecast_date_planned_end": self.forecast_date_end,
+                "forecast_date_planned_start": self.forecast_date_start,
+            }
+        )
+        return values
+
+    def _timesheet_create_project(self):
+        project = super()._timesheet_create_project()
+        if self.product_id.project_template_id and self.product_id.forecast_role_id:
+            project.tasks.write(
+                {
+                    "forecast_role_id": self.product_id.forecast_role_id.id,
+                    "date_end": self.forecast_date_end,
+                    "date_planned_start": self.forecast_date_start,
+                }
+            )
+        return project
diff --git a/project_forecast_line/readme/CONTRIBUTORS.rst b/project_forecast_line/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000000..ed6bc85941
--- /dev/null
+++ b/project_forecast_line/readme/CONTRIBUTORS.rst
@@ -0,0 +1,2 @@
+* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
+* Maksym Yankin <maksym.yankin@camptocamp.com>
diff --git a/project_forecast_line/readme/DESCRIPTION.rst b/project_forecast_line/readme/DESCRIPTION.rst
new file mode 100644
index 0000000000..b2e45eba26
--- /dev/null
+++ b/project_forecast_line/readme/DESCRIPTION.rst
@@ -0,0 +1,3 @@
+This module allows to plan your resources using forecast lines.
+For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees.
+Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed".
diff --git a/project_forecast_line/security/forecast_line_security.xml b/project_forecast_line/security/forecast_line_security.xml
new file mode 100644
index 0000000000..bd57ee533d
--- /dev/null
+++ b/project_forecast_line/security/forecast_line_security.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo noupdate="1">
+    <record id="group_forecast_line_on_quotation" model="res.groups">
+        <field name="name">Manage Forecast Dates on Quotations</field>
+        <field name="category_id" ref="base.module_category_hidden" />
+    </record>
+
+    <record model="ir.rule" id="forecast_line_comp_rule">
+        <field name="name">Forecast multi-company</field>
+        <field name="model_id" ref="model_forecast_line" />
+        <field name="domain_force">[('company_id', 'in', company_ids)]</field>
+    </record>
+</odoo>
diff --git a/project_forecast_line/security/ir.model.access.csv b/project_forecast_line/security/ir.model.access.csv
new file mode 100644
index 0000000000..9001101172
--- /dev/null
+++ b/project_forecast_line/security/ir.model.access.csv
@@ -0,0 +1,6 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+project_forecast_line.access_forecast_line,access_forecast_line,project_forecast_line.model_forecast_line,base.group_user,1,0,0,0
+project_forecast_line.access_forecast_role,access_forecast_role,project_forecast_line.model_forecast_role,base.group_user,1,0,0,0
+project_forecast_line.access_forecast_role_hr_manager,access_forecast_role_hr_manager,project_forecast_line.model_forecast_role,hr.group_hr_user,1,1,1,0
+project_forecast_line.access_hr_employee_forecast_role,access_hr_employee_forecast_role,project_forecast_line.model_hr_employee_forecast_role,base.group_user,1,0,0,0
+project_forecast_line.access_hr_manager_forecast_role,access_hr_manager_forecast_role,project_forecast_line.model_hr_employee_forecast_role,hr.group_hr_user,1,1,1,0
diff --git a/project_forecast_line/static/description/icon.png b/project_forecast_line/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..ebd3d6aadab4c053b3b78109d9c6eb25027d9a67
GIT binary patch
literal 4738
zcma)Ai9b}|8=fRIh-|~yB4X^zWJ$sxvXylhStd)!l6`9;8kD`hC~L@?v4+Sl8GClg
zlI;5)CZ^x%Klq*b+<QKsd(WAB&hx(S^FHr+anC@D9twv-AQ1XH+8RcnPyTn&P=I@t
z-BLB^s2=EQX+Zw|`@L%{cnyIt$=}gXGYLrF%m}{6JkAq3;HZXz1d39st&dQIy?kl6
zYv@~SJF4@D`YVa<rD;@56s9AaNrQ(?tK?W1N_C+)hv0#+w9FnJLsJ)+S`skTNJ`cO
zCaIgRHqPvV>!v8>9?Cy_Ew*t4i>PY$Zd?AYyxlSL^p9Ihy8~si1SGy_2dc<WmazZ-
zcN6CI5U%r`p25MvkWd1FkdeU+Y3c9p9~)zXTw#HFa#2GR6clXP^G!tlp6=h3Whr7n
zutayrI`%?*kPrwLH}^RR#WPg_rq*Rm#xD`{oaV<&)8Ei6a&mGaN!^=2YL%3f()d2}
zK0Tg`S0PKO-+mW=la`Z{bA4?MgTcIvj_ymyF#8~mKuD|K7HG8LTa7=pkVt6`*bUp{
z>gnl8(h>Yo=QGs9DtG<5FX=p_aC9x4nxQf^7ekN`6}7ugs~l9-mY<*ha3FIP7jUp*
z*v@d->(`eTA>@<D#^Z<s41OBS`dD3!Bdr#QIZS^0_IPVbF*)o=W7}k6YD&*=)FAU_
z(LLb_V(t6)?+Jw1SR%^-3g6e)S6yArauEsdYr_3WShlpX!kz3nEMQ}Yez3p~wY+|B
zeAkNS$?n5Qq&%v|e3Ztnm_jWv7~9ixkdK&6^4Xuuj<`R+H?u;>99Y$Wp!l{FNgEXD
zDne4Stf)xs)pa$-$V=QPrkCd$HRgI!B*8Y-7!y{Nm6g4{y*nOAznW_POa4R6z;oqd
zN9c^yZJi$82&|!@;re))P-mIGR_@?`$;tc|JLvjFO+Jk7kWWufhlYl}#`DIPLtqm3
z-}#=`x>U<rQ&WQ?wR_&YcI~!E(yJ=nLMLZu>HWv4d|YM|lam-@<J{rZn0z`K8tuDx
z8K6*<b;N6-yTv9V3s~n?Q+xvXISTva$rC+2I>p53WD<$AzdUf2K^JBq;`-=O_$~|%
zulHRa<5Q0)H;MW6>%l%l0Y{rM-p$PoiVvI2di{DByw1tVA$Bmk?i}yhMbm-z6;JtM
znxmqk_V)HxR-Ed|lRtjk&A4&l`zLV`k+AKEN@$7;HaJ-6@NBrf{i>j#;LV%qYCFZn
z#Umplkqo>y6cpHFAFq!Qii<_4>dMbOWbyOy5oURr`883RE=|dQD_=j2y~N$9$Rzmp
z20~n1@UOSMgM)s$!eDD_cEJIq8gbF9zOI~lYkS*cRffGFH+O!!`R`U&R#};cyL+hy
z|79rN=PKI7qy$sFR*>~{twFTr<3|Xjt*tGn{(WWTUK^g)NA>~3rJJ;fHQu=SXqe<J
z9O+=atl2<*1E9mq%sf9opLGjYd?Z?TpS&~I?p`+Jgv`SI=9q3U@0etDLBjE<>_I2G
zkj3rokb{-Mg#~*rE6JObYU#?sep^#@HX&!eci%eN+xz^$DEe=uJ46oCRjfV4-UY^(
z+gt2Gm)>`_Q~!K=H}hs<m;HFDRjz^G;o4|f6KRc5JF`Z@>gx7%y%t(sBFc?f(%uQ-
z=H?E{isx4Py+5FmqLb%y=8Q}Y3kxF>wR6eZGP1IVR0G}J-D0=W9ix&&MqH||U%wti
zPESvl^O$*ZyfcTk4#c~+0Q~)DasJEw8JnA%2`MAv<IBs-{^Y9C(*9zMbcT5@ieUEP
zp6~&AY&c(E$$P0cM>Cp|BFMq4$i%|JLN{4#wFr+=Gc+7*3_c=e#d9fAl+C7_XQ0$W
zczAf~>gs}zwhs69aySV&Is7s*m7hMbJEGbdGYnEPGYuHO<Z8u>h>CJ(NSz-WO&{FX
z@|%!%pIZ6#3nf1SAeWVq33BmnGd49fwYGk`IeA@_!$6j@D)Xwt;i+^cc6`;RGoE*#
zug}fh{kD;mjg8Gm<_@QdWK7}ks*8(@c8Efpq<Q%jY3W4wNCwp-j-R_bJLQfcEjlb~
zJ~zOk`x`G%QRNpDF!0_=S4+3+jB{{s;P|c1K0i0t`jf}qQEWDSaMedAuaZFM>+d%Z
zN#arRi+QU8Y55Of%#I8U3S%aRcCfx8kGLGZk+vgVQmR|d;Fy`2(bUvzYHG@d^-fP`
zfFTZGh^wpXKhzh9iP`FrsXWXtH2wYk(P;EAtsRe8e!>-+JR`-YrvZ-Ke$a--MzEmX
z3I5vCbGirtnw-W)Wl_;bvrRakENiI@A4y3`N8dzma;PQ?6Xj(tt|zC*p7o*vCL&3C
z*DNdTFX~0ct644NsMwNA&qB|gH!Cr3eb;VnXqcFseA{4vikce6!g8=ZJG){ESdi((
zLRm9eCiPV=IaHDL0XrKo8Z)!{I=djGrRw2;jEqdv$x>P=FV%$$tSl@oCI3CEAX<gA
zxIzJar6(lpOg<_btbchWido2DVPPR*-Qs|Tj!sNW?71_^*mB{ciY*mhA+CA9Ux^kb
z@m@*&&qCL05bc2JjNf0Rp%DfHFW*C>g%Y*t2CC=lRa$D=T3T90KcI)*I9!mafMAq^
zf4B4woc#F%V`HnYtK-nOK%-&I%)`E8-}?IKmr$aS;&}c<r@1yfaLw$`?@3F2X_hsv
z+1Hhod{*w&-QQYWbw)biaE%)LeBnozLP7=c+1c5io}N-6!=*@h`Q^n$Ux}`~j&IrO
z4BR)6wk?oc#GUBa*x38cA%0;2i1Ml`?<Y?hH2Aa4>8Hh$l9GaSy_|P{bwq>6S{*Ln
zoPH|K^un<}{p5#7Gl(x~A`z-)K`UgC&JKf74_mG4;BaSg+)DlFigrQgs2F&}QhKlv
z!EjDaV<RJ5QuG)D9G{t)`N50;mv`qPe4q(a@ZY-c=jUf<m%Hk4Wp{Tsv1c)yie4tQ
za@<@)s%30!>{_Ds3MqJRp{vrNN6u~X`8QsOYW6)bn<lO$RSp;|<>0_aK-M_9!vwz&
zA&p5%h+OJVlV_OQiz+oiqb02yKGoMxI_aR%<GsDTt>M&FhICcfSEx05yJ@eZt?8rL
z5EWTjV}co{ehl`_%&P$H|8y3I@^rE8uYhp)QR6oMQF94eBp<^uG&x!Cz1&||SoovX
zqy7ES{=TS~*y&1c{2SJ?ni{{s98IY+-BL^Y!UXrT&z|#N8v^&CP^hg$MMcGn*jQj!
zvBPB9m*SxU3-92)gP8Ohq6S|_gtU5UYO1}=TMhoe(_?8pQiWY-<}KU}1fs~M`RNEk
z<#eaLBN}F0P*heH|Kdem$ls>HCv}e!Yfu=ngmr_$qp_08N|`s8f|pBSL`@`ngrJa6
z$WANuNSAiBlt=8(Ld_s(@T8O@5jZy!6Vq6!)sJuAAk2#V{J(l6o9)Y^u1QOKE_Nr4
zIK$!aWO2)}x>h{J5Pz`NX!*I=W^^s*S2{omfG5Cw^6wuW*tSb><Vwr4^ML7Ms58b(
zESO<1t&IHaZ0zb#o|HJ^;lpKsGuj51WP$BvC~0#C$a(!GpW$g?-S(C4Le7UPQwSC3
zApzop0}T`kWn{$Un+i0~Vo%C!y>E{bQcJ6oU|H+c!T%qMJ!FAr65ws~^yI>|C6Iu+
zGWLANu?HHit|f-iB1s~0a=*uILaxJO)!AZ2pYNhgO<6fOF4jek&CJ|FAXble=UZD_
zkvdQ{SJw^F;d;?<p-C~Ts9DRYinouCp02KXrRrPL7~zfjxu~$y(<j99%LV#re&o}W
zi3xMLxNDRUG0U3X70YFSWv9HNqGDb_LE3}FM)jXJvW+Hu+Y?s<f`Yats~>I8Gy)pb
zrueO)r`HQmEd1*SM3<;pNhrsAUoWq2B9UFrl|B5`^T^0y?z4xg@zBd8W*W-D!9k#J
zZ2fM)&o2#RWu>O-xET`6Iy*Y78iPiikkr(X0EwY$b{-yyBpsm8^w&kGe9Fqb+uy8m
z9RK-~lAPRaFJsdAR-K8PTl{rbVeng^c|iz)yebWk{YcXs<@Gg&!>EX*y*wd-k*Lc)
z+$X;=CFS_QDf^yql1@)|_w87?!vkSQ4Nc9wygZI-5W{FgLo6~iJX{@vfyZ=!h%d8l
zOl_b(K0Xcz2vCK+y-2cQNNV1l?+n@PfL+^yAv2C<f+c}0u3sIQnQ3zDgTL9wV%L5h
ztPV3XG1bqse=m*tTaW<0nxo?Hf3&gTT6f=Fu-yc(*Y_rzQgugMT-=v0U$&e6^gAIF
zwKcH2ee$y-`_vG2MK1vY#qeSXRR1UvYi@5Z5QyS_S;=QNR$>86aa+99>ho_9sJC+u
z?L7fIM35m6K0dw@KXJ0DHlv1{+a{2r9Mo_bd*WPsWL0Hli8pSk_pPk|#)MiU;$3d;
zRRrRW$6x*01h&tmv*}1@BhYFGtY9#hii!%ALxcu|yw2cg@;WSVe+eHE!7sups-dB=
zG1Hh91UJeZ6c7|_ZKwye0zfX24G`cb_vsriG5u@v_aKk}(m~M+@~5_uB4d@41!k9M
z3_(s&v96(E$_Y92E9?TToa=-tF2%80U!sse(Dc<|5_%)!pkr;#bLgy!A^6RIjV-vF
zB?s3RVCu_KnO)&}`1r9@u(-Ec)}EG3;NC(*!_BquhH8wPj!t)xaY4d@Fbi~bi}1X2
zPm9{(`JLC-7MGWec^UJESHY&@c$8lXX#L*G!<q0wY^OeXo}8SFR3%ZWx&El3?+RSo
zsCFWiq9CeHPAkvOQy0o(FH<HaCSpmt*COdTBV4>J<8KidAySULZ$ON!@dbok0a`0M
zI$FPrrqgr5cuD|g1^&19jWnnNa%POR7{36T)xT+(kr#|8V}wA$!oz84X%TU%p1!`~
zVq!jCUNVBPWDK&QE#x8;dLv-Rw$^vBs|%r`lJ)%y4*dj&`~D{27b}_JnX2t4o$0~B
zig)iIqjoW|7PQuL%{Or*kowo?4`}giK!U}wOH-IixK8N?;vtZ4Q&SBtqauw*9*67W
z9)R7Q#w9DHcfW{I!ebr#Qm+RoNw~VX`EO1-kYv1-?^{_VdeCpLLobF+R60b@K!v$F
zRmjKl0MN?Bt9!xDHt0_e2Y4DeE^>8NRaIT(=Qr<8L`S_cE-=`fZEju{Vvoqn&+pGv
zPHWWDieZ1|Cm1|y8gNGPeXEy$lnvxKSe}GUQ|}B{I*>GzFN8h>j7FUV?)+3kp~B{@
z?<DD%f~pbJjq?Gjs;cJy+QdYKUF;UTKvP?r2*iAx2*$>waL~zKhT|}_!0baK$UhlM
z{vfRY0lhMqdncnkl0m)^E#<YKZ8Dtj&O_v5MFj%nj?&U5uSFdPhec4PYB&0RsIETj
zP7<~MFbcFIZ~!b83yLGhhGwBFSAYk<t+Xx|#ToLwDErque0+Q!JzDQelNS{ck+7<N
zZz57u=Hle^Dd3+BfOXN>kETmuDF~Mzb9IJ?g}G0C!VbZXC2?r15Gp$XnFs`TX6B`k
z=ntj;8YpbnNk8f6>G2k;NS6^|Yd%q_H1loJ&yTgW3>Pm-lYVMUC@rw^-v$}@mcnen
zJbk0F(b?%KFpf<_05&MD0mYUb@c03{9GLt@-}CkyYwC-B>VnwVK4RikA-y-D#bsTh
zNO^c{esS^I*7WCpM0_nX0MFI}xWA`|cJvEh*|#Z$8#gX-DKO4`@tEaas-YpCooixC
zeGN+6_69n<#1nBOv)IXb&@q6d?_(1}3C<mS@87>~t{fDBCQ5*PY;A3oIJT<LT701+
zLu_cjx4Uj@EwEOGMMhHh>P95C%xVwX8H9wE;fcEk2hRgdl~4hAVGb!<$h7?9)JtWI
z9OtPoAw4;zWD^iKL&&GViuW|vpa1H+2DprP7akEPd9OAgF!1fk#JoXyON(l>{nVot
zsotKR=&kjF%WbvV`fb0+kiTNc3v}&3F<abVq~WpVQ<tJ+j_w#K`1F?Anva>1;#qBN
z?e*jH4JaxaN{R||JsexU;#`{SLAl(&rG0Giyf4OPoF(k~Gyq@zQ(<d<KHTrKB-1e`
zg2il|VW*mr3LIwe^78gH)kRS>vB6ur|KVh02i#$PUi~bNBie2hUnO=%Is{)e+hW*}
uAIkjsQl(|Q&Ap<k3Oo~Ql!7b%J@;($>!i_cbR;-bg51$G&?rUOhW!sxvnihd

literal 0
HcmV?d00001

diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html
new file mode 100644
index 0000000000..a57a3348ed
--- /dev/null
+++ b/project_forecast_line/static/description/index.html
@@ -0,0 +1,423 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="https://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<head>
+<meta https-equiv="Content-Type" content="text/html; charset=utf-8" />
+<meta name="generator" content="Docutils 0.15.1: https://docutils.sourceforge.net/" />
+<title>Project Forecast Lines</title>
+<style type="text/css">
+
+/*
+:Author: David Goodger (goodger@python.org)
+:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
+:Copyright: This stylesheet has been placed in the public domain.
+
+Default cascading style sheet for the HTML output of Docutils.
+
+See https://docutils.sf.net/docs/howto/html-stylesheets.html for how to
+customize this style sheet.
+*/
+
+/* used to remove borders from tables and images */
+.borderless, table.borderless td, table.borderless th {
+  border: 0 }
+
+table.borderless td, table.borderless th {
+  /* Override padding for "table.docutils td" with "! important".
+     The right padding separates the table cells. */
+  padding: 0 0.5em 0 0 ! important }
+
+.first {
+  /* Override more specific margin styles with "! important". */
+  margin-top: 0 ! important }
+
+.last, .with-subtitle {
+  margin-bottom: 0 ! important }
+
+.hidden {
+  display: none }
+
+.subscript {
+  vertical-align: sub;
+  font-size: smaller }
+
+.superscript {
+  vertical-align: super;
+  font-size: smaller }
+
+a.toc-backref {
+  text-decoration: none ;
+  color: black }
+
+blockquote.epigraph {
+  margin: 2em 5em ; }
+
+dl.docutils dd {
+  margin-bottom: 0.5em }
+
+object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
+  overflow: hidden;
+}
+
+/* Uncomment (and remove this text!) to get bold-faced definition list terms
+dl.docutils dt {
+  font-weight: bold }
+*/
+
+div.abstract {
+  margin: 2em 5em }
+
+div.abstract p.topic-title {
+  font-weight: bold ;
+  text-align: center }
+
+div.admonition, div.attention, div.caution, div.danger, div.error,
+div.hint, div.important, div.note, div.tip, div.warning {
+  margin: 2em ;
+  border: medium outset ;
+  padding: 1em }
+
+div.admonition p.admonition-title, div.hint p.admonition-title,
+div.important p.admonition-title, div.note p.admonition-title,
+div.tip p.admonition-title {
+  font-weight: bold ;
+  font-family: sans-serif }
+
+div.attention p.admonition-title, div.caution p.admonition-title,
+div.danger p.admonition-title, div.error p.admonition-title,
+div.warning p.admonition-title, .code .error {
+  color: red ;
+  font-weight: bold ;
+  font-family: sans-serif }
+
+/* Uncomment (and remove this text!) to get reduced vertical space in
+   compound paragraphs.
+div.compound .compound-first, div.compound .compound-middle {
+  margin-bottom: 0.5em }
+
+div.compound .compound-last, div.compound .compound-middle {
+  margin-top: 0.5em }
+*/
+
+div.dedication {
+  margin: 2em 5em ;
+  text-align: center ;
+  font-style: italic }
+
+div.dedication p.topic-title {
+  font-weight: bold ;
+  font-style: normal }
+
+div.figure {
+  margin-left: 2em ;
+  margin-right: 2em }
+
+div.footer, div.header {
+  clear: both;
+  font-size: smaller }
+
+div.line-block {
+  display: block ;
+  margin-top: 1em ;
+  margin-bottom: 1em }
+
+div.line-block div.line-block {
+  margin-top: 0 ;
+  margin-bottom: 0 ;
+  margin-left: 1.5em }
+
+div.sidebar {
+  margin: 0 0 0.5em 1em ;
+  border: medium outset ;
+  padding: 1em ;
+  background-color: #ffffee ;
+  width: 40% ;
+  float: right ;
+  clear: right }
+
+div.sidebar p.rubric {
+  font-family: sans-serif ;
+  font-size: medium }
+
+div.system-messages {
+  margin: 5em }
+
+div.system-messages h1 {
+  color: red }
+
+div.system-message {
+  border: medium outset ;
+  padding: 1em }
+
+div.system-message p.system-message-title {
+  color: red ;
+  font-weight: bold }
+
+div.topic {
+  margin: 2em }
+
+h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
+h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
+  margin-top: 0.4em }
+
+h1.title {
+  text-align: center }
+
+h2.subtitle {
+  text-align: center }
+
+hr.docutils {
+  width: 75% }
+
+img.align-left, .figure.align-left, object.align-left, table.align-left {
+  clear: left ;
+  float: left ;
+  margin-right: 1em }
+
+img.align-right, .figure.align-right, object.align-right, table.align-right {
+  clear: right ;
+  float: right ;
+  margin-left: 1em }
+
+img.align-center, .figure.align-center, object.align-center {
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+table.align-center {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.align-left {
+  text-align: left }
+
+.align-center {
+  clear: both ;
+  text-align: center }
+
+.align-right {
+  text-align: right }
+
+/* reset inner alignment in figures */
+div.align-right {
+  text-align: inherit }
+
+/* div.align-center * { */
+/*   text-align: left } */
+
+.align-top    {
+  vertical-align: top }
+
+.align-middle {
+  vertical-align: middle }
+
+.align-bottom {
+  vertical-align: bottom }
+
+ol.simple, ul.simple {
+  margin-bottom: 1em }
+
+ol.arabic {
+  list-style: decimal }
+
+ol.loweralpha {
+  list-style: lower-alpha }
+
+ol.upperalpha {
+  list-style: upper-alpha }
+
+ol.lowerroman {
+  list-style: lower-roman }
+
+ol.upperroman {
+  list-style: upper-roman }
+
+p.attribution {
+  text-align: right ;
+  margin-left: 50% }
+
+p.caption {
+  font-style: italic }
+
+p.credits {
+  font-style: italic ;
+  font-size: smaller }
+
+p.label {
+  white-space: nowrap }
+
+p.rubric {
+  font-weight: bold ;
+  font-size: larger ;
+  color: maroon ;
+  text-align: center }
+
+p.sidebar-title {
+  font-family: sans-serif ;
+  font-weight: bold ;
+  font-size: larger }
+
+p.sidebar-subtitle {
+  font-family: sans-serif ;
+  font-weight: bold }
+
+p.topic-title {
+  font-weight: bold }
+
+pre.address {
+  margin-bottom: 0 ;
+  margin-top: 0 ;
+  font: inherit }
+
+pre.literal-block, pre.doctest-block, pre.math, pre.code {
+  margin-left: 2em ;
+  margin-right: 2em }
+
+pre.code .ln { color: grey; } /* line numbers */
+pre.code, code { background-color: #eeeeee }
+pre.code .comment, code .comment { color: #5C6576 }
+pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
+pre.code .literal.string, code .literal.string { color: #0C5404 }
+pre.code .name.builtin, code .name.builtin { color: #352B84 }
+pre.code .deleted, code .deleted { background-color: #DEB0A1}
+pre.code .inserted, code .inserted { background-color: #A3D289}
+
+span.classifier {
+  font-family: sans-serif ;
+  font-style: oblique }
+
+span.classifier-delimiter {
+  font-family: sans-serif ;
+  font-weight: bold }
+
+span.interpreted {
+  font-family: sans-serif }
+
+span.option {
+  white-space: nowrap }
+
+span.pre {
+  white-space: pre }
+
+span.problematic {
+  color: red }
+
+span.section-subtitle {
+  /* font-size relative to parent (h1..h6 element) */
+  font-size: 80% }
+
+table.citation {
+  border-left: solid 1px gray;
+  margin-left: 1px }
+
+table.docinfo {
+  margin: 2em 4em }
+
+table.docutils {
+  margin-top: 0.5em ;
+  margin-bottom: 0.5em }
+
+table.footnote {
+  border-left: solid 1px black;
+  margin-left: 1px }
+
+table.docutils td, table.docutils th,
+table.docinfo td, table.docinfo th {
+  padding-left: 0.5em ;
+  padding-right: 0.5em ;
+  vertical-align: top }
+
+table.docutils th.field-name, table.docinfo th.docinfo-name {
+  font-weight: bold ;
+  text-align: left ;
+  white-space: nowrap ;
+  padding-left: 0 }
+
+/* "booktabs" style (no vertical lines) */
+table.docutils.booktabs {
+  border: 0px;
+  border-top: 2px solid;
+  border-bottom: 2px solid;
+  border-collapse: collapse;
+}
+table.docutils.booktabs * {
+  border: 0px;
+}
+table.docutils.booktabs th {
+  border-bottom: thin solid;
+  text-align: left;
+}
+
+h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
+h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
+  font-size: 100% }
+
+ul.auto-toc {
+  list-style-type: none }
+
+</style>
+</head>
+<body>
+<div class="document" id="project-forecast-lines">
+<h1 class="title">Project Forecast Lines</h1>
+
+<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+!! This file is generated by oca-gen-addon-readme !!
+!! changes will be overwritten.                   !!
+!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
+<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="https://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
+<p>This module adds forecast line to project.</p>
+<p><strong>Table of contents</strong></p>
+<div class="contents local topic" id="contents">
+<ul class="simple">
+<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li>
+<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul>
+<li><a class="reference internal" href="#authors" id="id3">Authors</a></li>
+<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li>
+<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li>
+</ul>
+</li>
+</ul>
+</div>
+<div class="section" id="bug-tracker">
+<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1>
+<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/project/issues">GitHub Issues</a>.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+<a class="reference external" href="https://github.com/OCA/project/issues/new?body=module:%20project_forecast_line%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
+<p>Do not contact contributors directly about support or help with technical issues.</p>
+</div>
+<div class="section" id="credits">
+<h1><a class="toc-backref" href="#id2">Credits</a></h1>
+<div class="section" id="authors">
+<h2><a class="toc-backref" href="#id3">Authors</a></h2>
+<ul class="simple">
+<li>Camptocamp</li>
+</ul>
+</div>
+<div class="section" id="contributors">
+<h2><a class="toc-backref" href="#id4">Contributors</a></h2>
+<ul class="simple">
+<li><a class="reference external" href="https://www.camptocamp.com">Camptocamp</a><ul>
+<li>Alexandre Fayolle &lt;<a class="reference external" href="mailto:alexandre.fayolle&#64;camptocamp.com">alexandre.fayolle&#64;camptocamp.com</a>&gt;</li>
+<li>Maksym Yankin &lt;<a class="reference external" href="mailto:maksym.yankin&#64;camptocamp.com">maksym.yankin&#64;camptocamp.com</a>&gt;</li>
+</ul>
+</li>
+</ul>
+</div>
+<div class="section" id="maintainers">
+<h2><a class="toc-backref" href="#id5">Maintainers</a></h2>
+<p>This module is maintained by the OCA.</p>
+<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
+<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.</p>
+<p>This module is part of the <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line">OCA/project</a> project on GitHub.</p>
+<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
+</div>
+</div>
+</div>
+</body>
+</html>
diff --git a/project_forecast_line/tests/__init__.py b/project_forecast_line/tests/__init__.py
new file mode 100644
index 0000000000..93b26a9498
--- /dev/null
+++ b/project_forecast_line/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_forecast_line
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
new file mode 100644
index 0000000000..9a1a6a9d98
--- /dev/null
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -0,0 +1,612 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from datetime import date
+
+from freezegun import freeze_time
+
+from odoo.tests.common import Form, TransactionCase
+
+
+class BaseForecastLineTest(TransactionCase):
+    @classmethod
+    @freeze_time("2022-01-01")
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.env.company.write(
+            {
+                "forecast_line_granularity": "month",
+                "forecast_line_horizon": 6,  # months
+            }
+        )
+        cls.role_developer = cls.env["forecast.role"].create({"name": "developer"})
+        cls.role_consultant = cls.env["forecast.role"].create({"name": "consultant"})
+        cls.role_pm = cls.env["forecast.role"].create({"name": "project manager"})
+        cls.employee_dev = cls.env["hr.employee"].create({"name": "John Dev"})
+        cls.user_consultant = cls.env["res.users"].create(
+            {"name": "John Consultant", "login": "jc@example.com"}
+        )
+        cls.employee_consultant = cls.env["hr.employee"].create(
+            {"name": "John Consultant", "user_id": cls.user_consultant.id}
+        )
+        cls.employee_pm = cls.env["hr.employee"].create({"name": "John Peem"})
+        cls.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": cls.employee_dev.id,
+                "role_id": cls.role_developer.id,
+                "date_start": "2022-01-01",
+                "sequence": 1,
+            }
+        )
+        cls.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": cls.employee_consultant.id,
+                "role_id": cls.role_consultant.id,
+                "date_start": "2022-01-01",
+                "sequence": 1,
+            }
+        )
+        cls.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": cls.employee_pm.id,
+                "role_id": cls.role_pm.id,
+                "date_start": "2022-01-01",
+                "sequence": 1,
+            }
+        )
+
+        cls.product_dev_tm = cls.env["product.product"].create(
+            {
+                "name": "development time and material",
+                "detailed_type": "service",
+                "service_tracking": "task_in_project",
+                "price": 95,
+                "standard_price": 75,
+                "forecast_role_id": cls.role_developer.id,
+                "uom_id": cls.env.ref("uom.product_uom_hour").id,
+                "uom_po_id": cls.env.ref("uom.product_uom_hour").id,
+            }
+        )
+        cls.product_consultant_tm = cls.env["product.product"].create(
+            {
+                "name": "consultant time and material",
+                "detailed_type": "service",
+                "service_tracking": "task_in_project",
+                "price": 100,
+                "standard_price": 80,
+                "forecast_role_id": cls.role_consultant.id,
+                "uom_id": cls.env.ref("uom.product_uom_hour").id,
+                "uom_po_id": cls.env.ref("uom.product_uom_hour").id,
+            }
+        )
+
+        cls.product_pm_tm = cls.env["product.product"].create(
+            {
+                "name": "pm time and material",
+                "detailed_type": "service",
+                "service_tracking": "task_in_project",
+                "price": 120,
+                "standard_price": 100,
+                "forecast_role_id": cls.role_consultant.id,
+                "uom_id": cls.env.ref("uom.product_uom_hour").id,
+                "uom_po_id": cls.env.ref("uom.product_uom_hour").id,
+            }
+        )
+        cls.customer = cls.env["res.partner"].create({"name": "Some Customer"})
+
+
+class TestForecastLineEmployee(BaseForecastLineTest):
+    def test_employee_main_role(self):
+        self.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": self.employee_consultant.id,
+                "role_id": self.role_developer.id,
+                "date_start": "2021-01-01",
+                "date_end": "2021-12-31",
+                "sequence": 0,
+            }
+        )
+        self.assertEqual(self.employee_consultant.main_role_id, self.role_consultant)
+
+    def test_employee_job_role(self):
+        job = self.env["hr.job"].create(
+            {"name": "Developer", "role_id": self.role_developer.id}
+        )
+        employee = self.env["hr.employee"].create(
+            {"name": "John Dev", "job_id": job.id}
+        )
+        self.assertEqual(employee.main_role_id, self.role_developer)
+        self.assertEqual(len(employee.role_ids), 1)
+        self.assertEqual(employee.role_ids.rate, 100)
+
+    def test_employee_job_role_change(self):
+        job1 = self.env["hr.job"].create(
+            {"name": "Consultant", "role_id": self.role_consultant.id}
+        )
+        job2 = self.env["hr.job"].create(
+            {"name": "Developer", "role_id": self.role_developer.id}
+        )
+        employee = self.env["hr.employee"].create(
+            {"name": "John Dev", "job_id": job2.id}
+        )
+        employee.job_id = job1
+        self.assertEqual(employee.main_role_id, self.role_consultant)
+        self.assertEqual(len(employee.role_ids), 1)
+        self.assertEqual(employee.role_ids.rate, 100)
+
+    @freeze_time("2022-01-01")
+    def test_employee_forecast(self):
+        lines = self.env["forecast.line"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("forecast_role_id", "=", self.role_consultant.id),
+                ("res_model", "=", "hr.employee.forecast.role"),
+            ]
+        )
+        self.assertEqual(len(lines), 6)  # 6 months horizon
+        self.assertEqual(
+            lines.mapped("forecast_hours"),
+            # number of working days in the first 6 months of 2022, no vacations
+            [21.0 * 8, 20.0 * 8, 23.0 * 8, 21.0 * 8, 22.0 * 8, 22.0 * 8],
+        )
+
+    @freeze_time("2022-01-01")
+    def test_employee_forecast_change_roles(self):
+        # employee becomes 50% consultant, 50% PM on Feb 1st
+        roles = self.employee_consultant.role_ids
+        roles.write({"date_end": "2022-01-31"})
+        lines = self.env["forecast.line"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("forecast_role_id", "=", self.role_consultant.id),
+                ("res_model", "=", "hr.employee.forecast.role"),
+            ]
+        )
+        self.assertEqual(len(lines), 1)  # 100% consultant role now ends on 31/01
+        self.assertEqual(lines.forecast_hours, 21.0 * 8)
+        self.env["hr.employee.forecast.role"].create(
+            [
+                {
+                    "employee_id": self.employee_consultant.id,
+                    "role_id": self.role_consultant.id,
+                    "date_start": "2022-02-01",
+                    "sequence": 1,
+                    "rate": 50,
+                },
+                {
+                    "employee_id": self.employee_consultant.id,
+                    "role_id": self.role_pm.id,
+                    "date_start": "2022-02-01",
+                    "sequence": 2,
+                    "rate": 50,
+                },
+            ]
+        )
+        lines = self.env["forecast.line"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("forecast_role_id", "=", self.role_consultant.id),
+            ]
+        )
+        self.assertEqual(len(lines), 6)  # 6 months horizon
+        self.assertEqual(
+            lines.mapped("forecast_hours"),
+            # number of days in the first 6 months of 2022
+            [
+                21.0 * 8,
+                20.0 * 8 / 2,
+                23.0 * 8 / 2,
+                21.0 * 8 / 2,
+                22.0 * 8 / 2,
+                22.0 * 8 / 2,
+            ],
+        )
+
+    @freeze_time("2022-01-01 12:00:00")
+    def test_forecast_with_calendar(self):
+        calendar = self.employee_dev.resource_calendar_id
+        self.env["resource.calendar.leaves"].create(
+            {
+                "name": "Easter monday",
+                "calendar_id": calendar.id,
+                "date_from": "2022-04-18 00:00:00",
+                "date_to": "2022-04-19 00:00:00",  # Easter
+                "time_type": "leave",
+            }
+        )
+        lines = self.env["forecast.line"].search(
+            [
+                ("employee_id", "=", self.employee_dev.id),
+                ("forecast_role_id", "=", self.role_developer.id),
+                ("res_model", "=", "hr.employee.forecast.role"),
+            ]
+        )
+        self.assertEqual(len(lines), 6)  # 6 months horizon
+        self.assertEqual(
+            lines.mapped("forecast_hours"),
+            # number of days in the first 6 months of 2022, minus easter in April
+            [21.0 * 8, 20.0 * 8, 23.0 * 8, (21.0 - 1) * 8, 22.0 * 8, 22.0 * 8],
+        )
+
+
+class TestForecastLineSales(BaseForecastLineTest):
+    @freeze_time("2022-01-01")
+    def test_draft_sale_order_creates_negative_forecast_forecast(self):
+        with Form(self.env["sale.order"]) as form:
+            form.partner_id = self.customer
+            form.date_order = "2022-01-10 08:00:00"
+            form.default_forecast_date_start = "2022-02-07"
+            form.default_forecast_date_end = "2022-02-20"
+            with form.order_line.new() as line:
+                line.product_id = self.product_dev_tm
+                line.product_uom_qty = 10  # 1 FTE sold
+                line.product_uom = self.env.ref("uom.product_uom_day")
+        so = form.save()
+        line = so.order_line[0]
+        self.assertEqual(line.forecast_date_start, date(2022, 2, 7))
+        self.assertEqual(line.forecast_date_end, date(2022, 2, 20))
+        forecast_lines = self.env["forecast.line"].search(
+            [
+                ("sale_line_id", "=", line.id),
+                ("res_model", "=", "sale.order.line"),
+            ]
+        )
+        self.assertEqual(len(forecast_lines), 1)  # 10 days on 2022-02-01 to 2022-02-10
+        self.assertEqual(forecast_lines.type, "forecast")
+        self.assertEqual(
+            forecast_lines.forecast_role_id,
+            self.product_dev_tm.forecast_role_id,
+        )
+        self.assertEqual(forecast_lines.forecast_hours, -10 * 8)
+        self.assertEqual(forecast_lines.cost, -10 * 8 * 75)
+        self.assertEqual(forecast_lines.date_from, date(2022, 2, 1))
+        self.assertEqual(forecast_lines.date_to, date(2022, 2, 28))
+
+    @freeze_time("2022-01-01")
+    def test_draft_sale_order_without_dates_no_forecast(self):
+        """a draft sale order with no dates on the line does not create forecast"""
+        with Form(self.env["sale.order"]) as form:
+            form.partner_id = self.customer
+            form.date_order = "2022-01-10 08:00:00"
+            form.default_forecast_date_start = "2022-02-07"
+            form.default_forecast_date_end = False
+            with form.order_line.new() as line:
+                line.product_id = self.product_dev_tm
+                line.product_uom_qty = 10  # 1 FTE sold
+                line.product_uom = self.env.ref("uom.product_uom_day")
+        so = form.save()
+        line = so.order_line[0]
+        self.assertEqual(line.forecast_date_start, date(2022, 2, 7))
+        self.assertEqual(line.forecast_date_end, False)
+        forecast_lines = self.env["forecast.line"].search(
+            [
+                ("sale_line_id", "=", line.id),
+                ("res_model", "=", "sale.order.line"),
+            ]
+        )
+        self.assertFalse(forecast_lines)
+
+    @freeze_time("2022-01-01")
+    def test_draft_sale_order_forecast_spread(self):
+        with Form(self.env["sale.order"]) as form:
+            form.partner_id = self.customer
+            form.date_order = "2022-01-10 08:00:00"
+            form.default_forecast_date_start = "2022-02-07"
+            form.default_forecast_date_end = "2022-04-17"
+            with form.order_line.new() as line:
+                line.product_id = self.product_dev_tm
+                line.product_uom_qty = 100  # sell 2 FTE
+                line.product_uom = self.env.ref("uom.product_uom_day")
+
+        so = form.save()
+        line = so.order_line[0]
+        self.assertEqual(line.forecast_date_start, date(2022, 2, 7))
+        self.assertEqual(line.forecast_date_end, date(2022, 4, 17))
+        forecast_lines = self.env["forecast.line"].search(
+            [
+                ("sale_line_id", "=", line.id),
+                ("res_model", "=", "sale.order.line"),
+            ]
+        )
+        self.assertEqual(len(forecast_lines), 3)
+        daily_ratio = 2 * 8  # 2 FTE * 8h days
+        self.assertAlmostEqual(
+            forecast_lines[0].forecast_hours,
+            -1 * daily_ratio * 16,  # 16 worked days between 2022 Feb 7 and Feb 28
+        )
+        self.assertAlmostEqual(
+            forecast_lines[1].forecast_hours,
+            -1 * daily_ratio * 23,  # 23 worked days in march 2022
+        )
+        self.assertAlmostEqual(
+            forecast_lines[2].forecast_hours,
+            -1 * daily_ratio * 11,  # 11 worked day between april 1 and 17 2022
+        )
+        self.assertEqual(
+            forecast_lines.mapped("date_from"),
+            [date(2022, 2, 1), date(2022, 3, 1), date(2022, 4, 1)],
+        )
+        self.assertEqual(
+            forecast_lines.mapped("date_to"),
+            [date(2022, 2, 28), date(2022, 3, 31), date(2022, 4, 30)],
+        )
+
+    @freeze_time("2022-01-01")
+    def test_confirm_order_sale_order_no_forecast_line(self):
+        with Form(self.env["sale.order"]) as form:
+            form.partner_id = self.customer
+            form.date_order = "2022-01-10 08:00:00"
+            form.default_forecast_date_start = "2022-02-14"
+            form.default_forecast_date_end = "2022-04-14"
+            with form.order_line.new() as line:
+                line.product_id = self.product_dev_tm
+                line.product_uom_qty = 60
+                line.product_uom = self.env.ref("uom.product_uom_day")
+
+        so = form.save()
+        so.action_confirm()
+        line = so.order_line[0]
+        forecast_lines = self.env["forecast.line"].search(
+            [
+                ("sale_line_id", "=", line.id),
+                ("res_model", "=", "sale.order.line"),
+            ]
+        )
+        self.assertFalse(forecast_lines)
+
+    @freeze_time("2022-01-01")
+    def test_confirm_order_sale_order_create_project_task_with_forecast_line(self):
+        with Form(self.env["sale.order"]) as form:
+            form.partner_id = self.customer
+            form.date_order = "2022-01-10 08:00:00"
+            form.default_forecast_date_start = "2022-02-14"
+            form.default_forecast_date_end = "2022-04-17"
+            with form.order_line.new() as line:
+                line.product_id = self.product_dev_tm
+                line.product_uom_qty = 45 * 2  # 2 FTE
+                line.product_uom = self.env.ref("uom.product_uom_day")
+        so = form.save()
+        so.action_confirm()
+        line = so.order_line[0]
+        task = self.env["project.task"].search([("sale_line_id", "=", line.id)])
+        forecast_lines = self.env["forecast.line"].search(
+            [("res_id", "=", task.id), ("res_model", "=", "project.task")]
+        )
+        self.assertEqual(len(forecast_lines), 3)
+        self.assertEqual(forecast_lines.mapped("forecast_role_id"), self.role_developer)
+        daily_ratio = 8 * 2  # 2 FTE
+        self.assertAlmostEqual(
+            forecast_lines[0].forecast_hours,
+            -1 * daily_ratio * 11,  # 11 working days on 2022-02-14 -> 2022-02-28
+        )
+        self.assertAlmostEqual(
+            forecast_lines[1].forecast_hours,
+            -1 * daily_ratio * 23,  # 23 working days on 2022-03-01 -> 2022-03-31
+        )
+        self.assertAlmostEqual(
+            forecast_lines[2].forecast_hours,
+            -1 * daily_ratio * 11,  # 11 working days on 2022-04-01 -> 2022-04-17
+        )
+
+
+class TestForecastLineTimesheet(BaseForecastLineTest):
+    def test_timesheet_forecast_lines(self):
+        with freeze_time("2022-01-01"):
+            with Form(self.env["sale.order"]) as form:
+                form.partner_id = self.customer
+                form.date_order = "2022-01-10 08:00:00"
+                form.default_forecast_date_start = "2022-02-14"
+                form.default_forecast_date_end = "2022-04-17"
+                with form.order_line.new() as line:
+                    line.product_id = self.product_dev_tm
+                    line.product_uom_qty = (
+                        45 * 2
+                    )  # 45 working days in the period, sell 2 FTE
+                    line.product_uom = self.env.ref("uom.product_uom_day")
+            so = form.save()
+            so.action_confirm()
+
+        with freeze_time("2022-02-14"):
+            line = so.order_line[0]
+            task = self.env["project.task"].search([("sale_line_id", "=", line.id)])
+            # timesheet 1d
+            self.env["account.analytic.line"].create(
+                {
+                    "employee_id": self.employee_dev.id,
+                    "task_id": task.id,
+                    "project_id": task.project_id.id,
+                    "unit_amount": 8,
+                }
+            )
+            forecast_lines = self.env["forecast.line"].search(
+                [("res_id", "=", task.id), ("res_model", "=", "project.task")]
+            )
+            self.assertEqual(len(forecast_lines), 3)
+            daily_ratio = (45 * 2 - 1) * 8 / 45
+            self.assertAlmostEqual(
+                forecast_lines[0].forecast_hours, -1 * daily_ratio * 11
+            )
+            self.assertAlmostEqual(
+                forecast_lines[1].forecast_hours, -1 * daily_ratio * 23
+            )
+            self.assertAlmostEqual(
+                forecast_lines[2].forecast_hours, -1 * daily_ratio * 11
+            )
+            self.assertEqual(
+                forecast_lines.mapped("date_from"),
+                [date(2022, 2, 1), date(2022, 3, 1), date(2022, 4, 1)],
+            )
+            self.assertEqual(
+                forecast_lines.mapped("date_to"),
+                [date(2022, 2, 28), date(2022, 3, 31), date(2022, 4, 30)],
+            )
+
+    def test_timesheet_forecast_lines_cron(self):
+        """check recomputation of forecast lines of tasks even if we don"t TS"""
+        self.test_timesheet_forecast_lines()
+        with freeze_time("2022-03-10"):
+            self.env["forecast.line"]._cron_recompute_all()
+            forecast_lines = self.env["forecast.line"].search(
+                [("res_model", "=", "project.task")]
+            )
+            self.assertEqual(len(forecast_lines), 2)
+            daily_ratio = (
+                8
+                * (45 * 2 - 1)
+                / 27  # 27 worked days between 2022-03-10 and 2022-04-17
+            )
+            self.assertAlmostEqual(
+                forecast_lines[0].forecast_hours,
+                -1
+                * daily_ratio
+                * 16,  # 16 worked days between 2022-03-10 and 2022-03-31
+            )
+            self.assertAlmostEqual(
+                forecast_lines[1].forecast_hours,
+                -1
+                * daily_ratio
+                * 11,  # 11 worked days between 2022-04-01 and 2022-04-17
+            )
+            self.assertEqual(
+                forecast_lines.mapped("date_from"),
+                [date(2022, 3, 1), date(2022, 4, 1)],
+            )
+            self.assertEqual(
+                forecast_lines.mapped("date_to"),
+                [date(2022, 3, 31), date(2022, 4, 30)],
+            )
+
+
+class TestForecastLineProject(BaseForecastLineTest):
+    @classmethod
+    @freeze_time("2022-01-01")
+    def setUpClass(cls):
+        super().setUpClass()
+        # for this test, we use a daily granularity
+        cls.env.company.write(
+            {
+                "forecast_line_granularity": "day",
+                "forecast_line_horizon": 2,  # months
+            }
+        )
+
+    def test_task_forecast_lines_consolidated_forecast(self):
+        with freeze_time("2022-01-01"):
+            employee_forecast = self.env["forecast.line"].search(
+                [
+                    ("employee_id", "=", self.employee_consultant.id),
+                    ("date_from", "=", "2022-02-14"),
+                ]
+            )
+            self.assertEqual(len(employee_forecast), 1)
+            project = self.env["project.project"].create({"name": "TestProject"})
+            # set project in stage "in progress" to get confirmed forecast
+            project.stage_id = self.env.ref("project.project_project_stage_1")
+            task = self.env["project.task"].create(
+                {
+                    "name": "Task1",
+                    "project_id": project.id,
+                    "forecast_role_id": self.role_consultant.id,
+                    "forecast_date_planned_start": "2022-02-14",
+                    "forecast_date_planned_end": "2022-02-14",
+                    "planned_hours": 6,
+                }
+            )
+            task.remaining_hours = 6
+            task.user_ids = self.user_consultant
+            forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
+            self.assertEqual(len(forecast), 1)
+            # using assertEqual on purpose here
+            self.assertEqual(forecast.forecast_hours, -6.0)
+            self.assertEqual(round(forecast.consolidated_forecast, 5), 0.75000)
+            self.assertEqual(
+                forecast.employee_resource_forecast_line_id.consolidated_forecast,
+                0.25,
+            )
+
+    def test_task_forecast_lines_consolidated_forecast_overallocation(self):
+        with freeze_time("2022-01-01"):
+            employee_forecast = self.env["forecast.line"].search(
+                [
+                    ("employee_id", "=", self.employee_consultant.id),
+                    ("date_from", "=", "2022-02-14"),
+                ]
+            )
+            self.assertEqual(len(employee_forecast), 1)
+            project = self.env["project.project"].create({"name": "TestProject"})
+            # set project in stage "in progress" to get confirmed forecast
+            project.stage_id = self.env.ref("project.project_project_stage_1")
+            task = self.env["project.task"].create(
+                {
+                    "name": "Task1",
+                    "project_id": project.id,
+                    "forecast_role_id": self.role_consultant.id,
+                    "forecast_date_planned_start": "2022-02-14",
+                    "forecast_date_planned_end": "2022-02-14",
+                    "planned_hours": 8,
+                }
+            )
+            task.remaining_hours = 10
+            task.user_ids = self.user_consultant
+            forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
+            self.assertEqual(len(forecast), 1)
+            # using assertEqual on purpose here
+            self.assertEqual(forecast.forecast_hours, -10.0)
+            self.assertEqual(forecast.consolidated_forecast, 1.25)
+            self.assertEqual(
+                forecast.employee_resource_forecast_line_id.consolidated_forecast,
+                -0.25,
+            )
+
+    def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks(
+        self,
+    ):
+        with freeze_time("2022-01-01"):
+            employee_forecast = self.env["forecast.line"].search(
+                [
+                    ("employee_id", "=", self.employee_consultant.id),
+                    ("date_from", "=", "2022-02-14"),
+                ]
+            )
+            self.assertEqual(len(employee_forecast), 1)
+            project = self.env["project.project"].create({"name": "TestProject"})
+            # set project in stage "in progress" to get confirmed forecast
+            project.stage_id = self.env.ref("project.project_project_stage_1")
+            task1 = self.env["project.task"].create(
+                {
+                    "name": "Task1",
+                    "project_id": project.id,
+                    "forecast_role_id": self.role_consultant.id,
+                    "forecast_date_planned_start": "2022-02-14",
+                    "forecast_date_planned_end": "2022-02-14",
+                    "planned_hours": 8,
+                }
+            )
+            task1.remaining_hours = 10
+            task1.user_ids = self.user_consultant
+            forecast1 = self.env["forecast.line"].search([("task_id", "=", task1.id)])
+            self.assertEqual(len(forecast1), 1)
+            task2 = self.env["project.task"].create(
+                {
+                    "name": "Task2",
+                    "project_id": project.id,
+                    "forecast_role_id": self.role_consultant.id,
+                    "forecast_date_planned_start": "2022-02-14",
+                    "forecast_date_planned_end": "2022-02-14",
+                    "planned_hours": 4,
+                }
+            )
+            task2.remaining_hours = 4
+            task2.user_ids = self.user_consultant
+            forecast2 = self.env["forecast.line"].search([("task_id", "=", task2.id)])
+            # using assertEqual on purpose here
+            self.assertEqual(
+                forecast1.employee_resource_forecast_line_id,
+                forecast2.employee_resource_forecast_line_id,
+            )
+            self.assertEqual(
+                round(
+                    forecast1.employee_resource_forecast_line_id.consolidated_forecast,
+                    5,
+                ),
+                -0.75000,
+            )
diff --git a/project_forecast_line/views/forecast_line_views.xml b/project_forecast_line/views/forecast_line_views.xml
new file mode 100644
index 0000000000..7b92e72d75
--- /dev/null
+++ b/project_forecast_line/views/forecast_line_views.xml
@@ -0,0 +1,154 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="view_forecast_line_search" model="ir.ui.view">
+        <field name="model">forecast.line</field>
+        <field name="arch" type="xml">
+            <search>
+                <field name="name" />
+                <field name="forecast_role_id" />
+                <field name="project_id" />
+                <field name="employee_id" />
+                <field name="company_id" groups="base.group_multi_company" />
+                <filter
+                    string="Forecast"
+                    domain="[('type', '=', 'forecast')]"
+                    name="forecast_forcast_filter"
+                />
+                <filter
+                    string="Confirmed"
+                    domain="[('type', '=', 'confirmed')]"
+                    name="forecast_confirmed_filter"
+                />
+                <group expand="0" string="Group By">
+                    <filter
+                        string="Project"
+                        name="project"
+                        domain="[]"
+                        context="{'group_by': 'project_id'}"
+                    />
+                    <filter
+                        string="Role"
+                        name="role"
+                        domain="[]"
+                        context="{'group_by': 'forecast_role_id'}"
+                    />
+                    <filter
+                        string="Date from"
+                        name="date_from"
+                        domain="[]"
+                        context="{'group_by':'date_from:month'}"
+                    />
+                    <filter
+                        string="Employee"
+                        name="employee"
+                        domain="[]"
+                        context="{'group_by': 'employee_id'}"
+                    />
+                    <filter
+                        string="Model"
+                        name="model"
+                        domain="[]"
+                        context="{'group_by': 'res_model'}"
+                    />
+                </group>
+            </search>
+        </field>
+    </record>
+
+    <record id="view_forecast_line_list" model="ir.ui.view">
+        <field name="model">forecast.line</field>
+        <field name="arch" type="xml">
+            <tree>
+                <field name="name" />
+                <field name="date_from" />
+                <field name="date_to" />
+                <field name="forecast_hours" />
+                <field name="cost" />
+                <field name="consolidated_forecast" />
+                <field name="forecast_role_id" />
+                <field name="currency_id" invisible="1" />
+            </tree>
+        </field>
+    </record>
+    <record id="view_forecast_line_graph" model="ir.ui.view">
+        <field name="model">forecast.line</field>
+        <field name="arch" type="xml">
+            <graph type="bar" stacked="0">
+                <field name="date_from" type="row" />
+                <field name="forecast_role_id" type="col" />
+                <field name="consolidated_forecast" type="measure" />
+                <field name="cost" type="measure" />
+                <field name="forecast_hours" type="measure" />
+                <field name="currency_id" invisible="1" />
+            </graph>
+        </field>
+    </record>
+    <record id="view_forecast_line_graph_stacked" model="ir.ui.view">
+        <field name="model">forecast.line</field>
+        <field name="priority" eval="20" />
+        <field name="arch" type="xml">
+            <graph type="bar" stacked="1">
+                <field name="date_from" type="row" />
+                <field name="employee_id" type="col" />
+                <field name="project_id" type="col" />
+                <field name="consolidated_forecast" type="measure" />
+            </graph>
+        </field>
+    </record>
+    <record id="view_forecast_line_pivot" model="ir.ui.view">
+        <field name="model">forecast.line</field>
+        <field name="arch" type="xml">
+            <pivot>
+                <field name="date_from" type="col" />
+                <field name="forecast_role_id" type="row" />
+                <field name="consolidated_forecast" type="measure" />
+                <field name="cost" type="measure" />
+                <field name="forecast_hours" type="measure" />
+                <field name="currency_id" invisible="1" />
+            </pivot>
+        </field>
+    </record>
+
+    <record id="action_forecast_lines" model="ir.actions.act_window">
+        <field name="name">Forecast</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name="res_model">forecast.line</field>
+        <field name="view_mode">tree,graph,pivot</field>
+    </record>
+    <record id="action_forecast_lines_consolidated" model="ir.actions.act_window">
+        <field name="name">Forecast (Consolidated)</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name="res_model">forecast.line</field>
+        <field name="view_mode">graph,pivot,tree</field>
+        <field name="view_id" ref="view_forecast_line_graph_stacked" />
+        <field name="search_view_id" ref="view_forecast_line_search" />
+        <field name="domain">[('employee_id', '!=', False)]</field>
+        <field
+            name="context"
+        >{'graph_groupbys': ['date_from:month', 'project_id']}</field>
+    </record>
+
+    <menuitem
+        id="forecast_menu_root"
+        name="Forecast"
+        sequence="75"
+        groups="base.group_user"
+        action="action_forecast_lines"
+        web_icon="project_forecast_line,static/description/icon.png"
+    />
+    <menuitem
+        id="menu_forecast_config"
+        name="Configuration"
+        parent="forecast_menu_root"
+        sequence="100"
+        groups="hr.group_hr_user"
+    />
+    <menuitem
+        id="menu_forecast_line_consolidated"
+        parent="forecast_menu_root"
+        sequence="10"
+        name="Forecast"
+        action="action_forecast_lines_consolidated"
+    />
+
+</odoo>
diff --git a/project_forecast_line/views/forecast_role_views.xml b/project_forecast_line/views/forecast_role_views.xml
new file mode 100644
index 0000000000..a3b829945a
--- /dev/null
+++ b/project_forecast_line/views/forecast_role_views.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="view_forecast_role_search" model="ir.ui.view">
+        <field name="model">forecast.role</field>
+        <field name="arch" type="xml">
+            <search>
+                <field name="name" />
+            </search>
+        </field>
+    </record>
+
+    <record id="view_forecast_role_list" model="ir.ui.view">
+        <field name="model">forecast.role</field>
+        <field name="arch" type="xml">
+            <tree>
+                <field name="name" />
+            </tree>
+        </field>
+    </record>
+    <record id="view_forecast_role_form" model="ir.ui.view">
+        <field name="model">forecast.role</field>
+        <field name="arch" type="xml">
+            <form>
+                <sheet>
+                    <group>
+                        <field name="name" />
+                        <field name="description" />
+                    </group>
+                </sheet>
+            </form>
+        </field>
+    </record>
+    <record id="action_forecast_roles" model="ir.actions.act_window">
+        <field name="name">Forecast Roles</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name="res_model">forecast.role</field>
+        <field name="view_mode">tree,form</field>
+    </record>
+
+    <menuitem
+        id="menu_forecast_role"
+        parent="menu_forecast_config"
+        sequence="20"
+        name="Forecast Roles"
+        action="action_forecast_roles"
+    />
+
+</odoo>
diff --git a/project_forecast_line/views/hr_employee_views.xml b/project_forecast_line/views/hr_employee_views.xml
new file mode 100644
index 0000000000..afbad9594a
--- /dev/null
+++ b/project_forecast_line/views/hr_employee_views.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="view_employee_form" model="ir.ui.view">
+        <field name="model">hr.employee</field>
+        <field name="inherit_id" ref="hr.view_employee_form" />
+        <field name="arch" type="xml">
+            <xpath expr="//page[@name='hr_settings']/group[1]" position="inside">
+                <group string="Forecast Roles" name="forecast_roles">
+                    <field name="role_ids">
+                        <tree editable="bottom">
+                            <field name="sequence" widget="handle" />
+                            <field name="role_id" />
+                            <field name="date_start" />
+                            <field name="date_end" />
+                            <field name="rate" />
+                        </tree>
+                    </field>
+                </group>
+            </xpath>
+        </field>
+    </record>
+
+    <record id="view_hr_job_form" model="ir.ui.view">
+        <field name="model">hr.job</field>
+        <field name="inherit_id" ref="hr.view_hr_job_form" />
+        <field name="arch" type="xml">
+            <xpath expr="//sheet/div[hasclass('oe_title')]" position="after">
+                <group><group><field name="role_id" /></group></group>
+            </xpath>
+        </field>
+    </record>
+</odoo>
diff --git a/project_forecast_line/views/product_views.xml b/project_forecast_line/views/product_views.xml
new file mode 100644
index 0000000000..66db9d076c
--- /dev/null
+++ b/project_forecast_line/views/product_views.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="product_template_form" model="ir.ui.view">
+        <field name="model">product.template</field>
+        <field name="inherit_id" ref="product.product_template_only_form_view" />
+        <field name="arch" type="xml">
+            <field name="barcode" position="after">
+                <field name="forecast_role_id" />
+            </field>
+        </field>
+    </record>
+</odoo>
diff --git a/project_forecast_line/views/project_project_stage_views.xml b/project_forecast_line/views/project_project_stage_views.xml
new file mode 100644
index 0000000000..c93ccc0b17
--- /dev/null
+++ b/project_forecast_line/views/project_project_stage_views.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="project_project_stage_view_tree" model="ir.ui.view">
+        <field name="model">project.project.stage</field>
+        <field name="inherit_id" ref="project.project_project_stage_view_tree" />
+        <field name="arch" type="xml">
+            <field name="name" position="after">
+                <field name="forecast_line_type" />
+            </field>
+        </field>
+    </record>
+    <record id="project_project_stage_view_form_quick_create" model="ir.ui.view">
+        <field name="model">project.project.stage</field>
+        <field
+            name="inherit_id"
+            ref="project.project_project_stage_view_form_quick_create"
+        />
+        <field name="arch" type="xml">
+            <field name="name" position="after">
+                <field name="forecast_line_type" />
+            </field>
+        </field>
+    </record>
+    <record id="project_project_stage_view_form" model="ir.ui.view">
+        <field name="inherit_id" ref="project.project_project_stage_view_form" />
+        <field name="model">project.project.stage</field>
+        <field name="arch" type="xml">
+            <field name="active" position="after">
+                <field name="forecast_line_type" />
+            </field>
+        </field>
+    </record>
+
+</odoo>
diff --git a/project_forecast_line/views/project_task_views.xml b/project_forecast_line/views/project_task_views.xml
new file mode 100644
index 0000000000..3b45b166f4
--- /dev/null
+++ b/project_forecast_line/views/project_task_views.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="view_task_form" model="ir.ui.view">
+        <field name="model">project.task</field>
+        <field name="inherit_id" ref="project.view_task_form2" />
+        <field name="arch" type="xml">
+            <field name="parent_id" position="before">
+                <field name="forecast_role_id" />
+            </field>
+            <field name="analytic_account_id" position="before">
+                <field name="forecast_date_planned_start" />
+                <field name="forecast_date_planned_end" />
+            </field>
+        </field>
+    </record>
+</odoo>
diff --git a/project_forecast_line/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml
new file mode 100644
index 0000000000..e273587afb
--- /dev/null
+++ b/project_forecast_line/views/res_config_settings_views.xml
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="res_config_settings_view_form" model="ir.ui.view">
+        <field name="name">res.config.settings.view.form.inherit.forecast</field>
+        <field name="model">res.config.settings</field>
+        <field name="inherit_id" ref="base.res_config_settings_view_form" />
+        <field name="arch" type="xml">
+            <xpath expr="//div[hasclass('settings')]" position="inside">
+                <div
+                    class="app_settings_block"
+                    data-string="Forecast"
+                    string="Forecast"
+                    data-key="project_forecast_line"
+                    groups="hr.group_hr_user"
+                >
+                    <h2>Forecast Management</h2>
+                    <div class="row mt16 o_settings_container" id="forecast_management">
+                        <div
+                            class="col-12 col-lg-6 o_setting_box"
+                            id="forecast_line_settings"
+                        >
+                            <div class="o_setting_left_pane">
+                                <field name="group_forecast_line_on_quotation" />
+                            </div>
+                            <div class="o_setting_right_pane">
+                                <label for="group_forecast_line_on_quotation" />
+                                <div class="text-muted">
+                                    Allow to see forecast dates on quotations
+                                </div>
+                                <div class="content-group">
+                                    <div class="mt8">
+                                        <label for="forecast_line_granularity" />
+                                        <div class="text-muted">
+                                            Periodicity of the forecast that will be generated
+                                        </div>
+                                        <field
+                                            name="forecast_line_granularity"
+                                            required="1"
+                                            class="o_light_label"
+                                        />
+                                    </div>
+                                    <div class="mt8">
+                                        <label for="forecast_line_horizon" />
+                                        <div class="text-muted">
+                                            Number of month for the forecast planning
+                                        </div>
+                                        <field
+                                            name="forecast_line_horizon"
+                                            required="1"
+                                            class="o_field_integer o_field_number o_field_widget o_input oe_inline col-lg-2"
+                                        />
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </xpath>
+        </field>
+    </record>
+    <record id="forecast_config_settings_action" model="ir.actions.act_window">
+        <field name="name">Settings</field>
+        <field name="type">ir.actions.act_window</field>
+        <field name="res_model">res.config.settings</field>
+        <field name="view_mode">form</field>
+        <field name="target">inline</field>
+        <field name="context">{'module': 'forecast', 'bin_size': False}</field>
+    </record>
+
+    <menuitem
+        id="forecast_config_settings_menu_action"
+        name="Settings"
+        parent="menu_forecast_config"
+        sequence="0"
+        action="forecast_config_settings_action"
+        groups="base.group_system"
+    />
+</odoo>
diff --git a/project_forecast_line/views/sale_order_views.xml b/project_forecast_line/views/sale_order_views.xml
new file mode 100644
index 0000000000..bb6b0149ca
--- /dev/null
+++ b/project_forecast_line/views/sale_order_views.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="view_order_form" model="ir.ui.view">
+        <field name="model">sale.order</field>
+        <field name="inherit_id" ref="sale.view_order_form" />
+        <field name="arch" type="xml">
+            <field name="date_order" position="after">
+                <field
+                    name="default_forecast_date_start"
+                    groups="project_forecast_line.group_forecast_line_on_quotation"
+                />
+                <field
+                    name="default_forecast_date_end"
+                    groups="project_forecast_line.group_forecast_line_on_quotation"
+                />
+            </field>
+            <xpath
+                expr="//field[@name='order_line']/form//field[@name='product_id']"
+                position="before"
+            >
+                <field
+                    name="forecast_date_start"
+                    groups="project_forecast_line.group_forecast_line_on_quotation"
+                />
+                <field
+                    name="forecast_date_end"
+                    groups="project_forecast_line.group_forecast_line_on_quotation"
+                />
+            </xpath>
+            <xpath
+                expr="//field[@name='order_line']/tree//field[@name='customer_lead']"
+                position="after"
+            >
+                <field
+                    name="forecast_date_start"
+                    optional="show"
+                    groups="project_forecast_line.group_forecast_line_on_quotation"
+                />
+                <field
+                    name="forecast_date_end"
+                    optional="show"
+                    groups="project_forecast_line.group_forecast_line_on_quotation"
+                />
+            </xpath>
+        </field>
+    </record>
+
+</odoo>

From fa3d72ae576ab3aac37bd621dc958cb1950d9afc Mon Sep 17 00:00:00 2001
From: oca-ci <oca-ci@odoo-community.org>
Date: Wed, 17 Aug 2022 06:25:18 +0000
Subject: [PATCH 02/33] [UPD] Update project_forecast_line.pot

---
 project_forecast_line/i18n/project_forecast_line.pot | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/project_forecast_line/i18n/project_forecast_line.pot b/project_forecast_line/i18n/project_forecast_line.pot
index 3f97478e10..8d1a46263f 100644
--- a/project_forecast_line/i18n/project_forecast_line.pot
+++ b/project_forecast_line/i18n/project_forecast_line.pot
@@ -4,10 +4,8 @@
 #
 msgid ""
 msgstr ""
-"Project-Id-Version: Odoo Server 15.0+e\n"
+"Project-Id-Version: Odoo Server 15.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2022-08-09 11:54+0000\n"
-"PO-Revision-Date: 2022-08-09 11:54+0000\n"
 "Last-Translator: \n"
 "Language-Team: \n"
 "MIME-Version: 1.0\n"

From 5a2aacf781f1c808a35ad24fc7683a17cdcb02ce Mon Sep 17 00:00:00 2001
From: OCA-git-bot <oca-git-bot@odoo-community.org>
Date: Wed, 17 Aug 2022 06:28:49 +0000
Subject: [PATCH 03/33] [UPD] README.rst

---
 project_forecast_line/README.rst              | 77 ++++++++++++++++++-
 .../static/description/index.html             | 21 +++--
 2 files changed, 86 insertions(+), 12 deletions(-)

diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst
index c275fb4dbf..47a72f7f17 100644
--- a/project_forecast_line/README.rst
+++ b/project_forecast_line/README.rst
@@ -1 +1,76 @@
-TO BE GENERATED BY OCA BOT
+======================
+Project Forecast Lines
+======================
+
+.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+   !! This file is generated by oca-gen-addon-readme !!
+   !! changes will be overwritten.                   !!
+   !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
+    :target: https://odoo-community.org/page/development-status
+    :alt: Beta
+.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
+    :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+    :alt: License: AGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github
+    :target: https://github.com/OCA/project/tree/15.0/project_forecast_line
+    :alt: OCA/project
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+    :target: https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line
+    :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
+    :target: https://runbot.odoo-community.org/runbot/140/15.0
+    :alt: Try me on Runbot
+
+|badge1| |badge2| |badge3| |badge4| |badge5| 
+
+This module allows to plan your resources using forecast lines.
+For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees.
+Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed".
+
+**Table of contents**
+
+.. contents::
+   :local:
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues <https://github.com/OCA/project/issues>`_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us smashing it by providing a detailed and welcomed
+`feedback <https://github.com/OCA/project/issues/new?body=module:%20project_forecast_line%0Aversion:%2015.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+~~~~~~~
+
+* Camptocamp SA
+
+Contributors
+~~~~~~~~~~~~
+
+* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
+* Maksym Yankin <maksym.yankin@camptocamp.com>
+
+Maintainers
+~~~~~~~~~~~
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+   :alt: Odoo Community Association
+   :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+This module is part of the `OCA/project <https://github.com/OCA/project/tree/15.0/project_forecast_line>`_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html
index a57a3348ed..9e96d47fcd 100644
--- a/project_forecast_line/static/description/index.html
+++ b/project_forecast_line/static/description/index.html
@@ -1,9 +1,9 @@
 <?xml version="1.0" encoding="utf-8" ?>
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html xmlns="https://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
-<meta https-equiv="Content-Type" content="text/html; charset=utf-8" />
-<meta name="generator" content="Docutils 0.15.1: https://docutils.sourceforge.net/" />
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
 <title>Project Forecast Lines</title>
 <style type="text/css">
 
@@ -14,7 +14,7 @@
 
 Default cascading style sheet for the HTML output of Docutils.
 
-See https://docutils.sf.net/docs/howto/html-stylesheets.html for how to
+See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
 customize this style sheet.
 */
 
@@ -367,8 +367,10 @@ <h1 class="title">Project Forecast Lines</h1>
 !! This file is generated by oca-gen-addon-readme !!
 !! changes will be overwritten.                   !!
 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
-<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="https://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
-<p>This module adds forecast line to project.</p>
+<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
+<p>This module allows to plan your resources using forecast lines.
+For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will “consume” the work time capacity of the employees.
+Forecast lines also come in two states “forecast” or “confirmed”, depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type “forecast”, whereas tasks for project which are in a running state create lines with type “confirmed”.</p>
 <p><strong>Table of contents</strong></p>
 <div class="contents local topic" id="contents">
 <ul class="simple">
@@ -394,18 +396,15 @@ <h1><a class="toc-backref" href="#id2">Credits</a></h1>
 <div class="section" id="authors">
 <h2><a class="toc-backref" href="#id3">Authors</a></h2>
 <ul class="simple">
-<li>Camptocamp</li>
+<li>Camptocamp SA</li>
 </ul>
 </div>
 <div class="section" id="contributors">
 <h2><a class="toc-backref" href="#id4">Contributors</a></h2>
 <ul class="simple">
-<li><a class="reference external" href="https://www.camptocamp.com">Camptocamp</a><ul>
 <li>Alexandre Fayolle &lt;<a class="reference external" href="mailto:alexandre.fayolle&#64;camptocamp.com">alexandre.fayolle&#64;camptocamp.com</a>&gt;</li>
 <li>Maksym Yankin &lt;<a class="reference external" href="mailto:maksym.yankin&#64;camptocamp.com">maksym.yankin&#64;camptocamp.com</a>&gt;</li>
 </ul>
-</li>
-</ul>
 </div>
 <div class="section" id="maintainers">
 <h2><a class="toc-backref" href="#id5">Maintainers</a></h2>

From c99df8b9d38b67c3dda5ed1ad8785ec965b51eec Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Wed, 17 Aug 2022 14:47:32 +0200
Subject: [PATCH 04/33] project_forecast_line: improve documentation

---
 project_forecast_line/README.rst              | 76 +++++++++++++++-
 .../readme/CONFIGURATION.rst                  | 20 +++++
 project_forecast_line/readme/DESCRIPTION.rst  | 28 +++++-
 project_forecast_line/readme/USAGE.rst        | 62 +++++++++++++
 .../static/description/index.html             | 87 ++++++++++++++++---
 5 files changed, 255 insertions(+), 18 deletions(-)
 create mode 100644 project_forecast_line/readme/CONFIGURATION.rst
 create mode 100644 project_forecast_line/readme/USAGE.rst

diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst
index 47a72f7f17..87c7d75b27 100644
--- a/project_forecast_line/README.rst
+++ b/project_forecast_line/README.rst
@@ -26,14 +26,86 @@ Project Forecast Lines
 |badge1| |badge2| |badge3| |badge4| |badge5| 
 
 This module allows to plan your resources using forecast lines.
-For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees.
-Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed".
+
+For each employee of the company, the module will generate forecast line
+records with a positive capacity based on their working time schedules. Then,
+tasks assigned to employees will generate forecast lines with a negative
+capacity which will "consume" the work time capacity of the employees.
+
+The idea is that you can then see the work capacity and scheduled work of
+people by summing the "forecasts" per time period. If you have more resources
+(positive forecast) than work (negative forecast) you will have a positive net
+sum. Otherwise you are in trouble and need to recruit or reschedule your
+work. Another way to use the report is checking when the work capacity of a
+department becomes positive (or high enough) in order to provide you potential
+customers with an estimate of when a project would be able to start.
+
+Forecast lines also come in two states "forecast" or "confirmed", depending on
+whether the consumption is confirmed or not. For instance, holidays requests
+and sales quotation lines create lines of type "forecast", whereas tasks for
+project which are in a running state create lines with type "confirmed".
+
 
 **Table of contents**
 
 .. contents::
    :local:
 
+Usage
+=====
+
+Forecast lines have the following data:
+
+* Forecast hours: it is positive for resources (employees) and negative for
+  things which consume time
+
+* From and To date which are the beginning and ending of the period of the
+  capacity
+
+* Consolidated forecast: this is a computed field, which is computed as follows:
+
+  * for costs (project tasks for instance) we take the absolute value of the
+    forecast hours (so it is a positive number)
+
+  * for resources (employee capacity for a period), we take the capacity and
+    substract all the costs for that employee on the same period. So it will be
+    positive if the employee still has some free time, and negative if he is
+    overloaded with work.
+
+
+Objects creating forecast lines:
+
+* employees with a forecast role will create forecast line with a positive
+  capacity and type "confirmed" for each day on which they work. This
+  information comes from their work calendar, and the different roles that are
+  linked to the employee.
+
+* draft sale orders (if enabled in the settings) will create forecast lines of
+  type "forecast" for each sale order line having a product with a forecast
+  role and start and end dates. The forecast hours are negative
+
+* confirmed sale orders don't create forecast lines. This is handled by the
+  tasks created at the confirmation of the sale order
+
+* project tasks create forecast lines if they have a linked role and start/end
+  date. The type of the line will depend on the related project's stage. The
+  forecast quantity is based on the remaining time of the task, which is spread
+  on the work days of the planned start and end date of the task. If the
+  current date is in the middle of the planned duration of the task, it is used
+  as the start date. If the planned end date is in the past the task does not
+  generate forecast lines (and you need to fix your planning). In case multiple
+  employees are assigned to the task the forecast is split equally between
+  them.
+
+* holiday requests create negative forecast lines with type "forecast" when
+  they are pending manager validation.
+
+* Validated holiday requests do not generate forecast lines, as they alter the
+  work calendar of the employee: the employee will not have a positive line
+  associated to his leave days.
+
+
+
 Bug Tracker
 ===========
 
diff --git a/project_forecast_line/readme/CONFIGURATION.rst b/project_forecast_line/readme/CONFIGURATION.rst
new file mode 100644
index 0000000000..c30fb59df9
--- /dev/null
+++ b/project_forecast_line/readme/CONFIGURATION.rst
@@ -0,0 +1,20 @@
+In the settings, you'll find a Forecast Management section where you can configure
+
+* the granularity of the forecast (day / week / month)
+* the horizon of the forecast (number of months)
+* if we want to manage forecast from quotations
+
+Then you can configure in the Forecast application the forecast roles. These are
+roles than can be linked to products and to employees, for instance "project
+manager" or "consultant".
+
+If you want to use the forecast on sales -> forecast on tasks chain, you should
+configure the service products that will be used on the sale order to give them
+a forecast role.
+
+Finally, you need to set roles on employees. You can set this on the Jobs, and
+when an employee is assigned a job, the job's role will be pushed to the
+employee. Or you can just set the role on the employee. The roles of employees
+have start and end dates, so you can manage people who will join the company in
+the future or people's internal job changes. You can also set a rate and handle
+people wearing multiple hats.
diff --git a/project_forecast_line/readme/DESCRIPTION.rst b/project_forecast_line/readme/DESCRIPTION.rst
index b2e45eba26..d86f5d98b8 100644
--- a/project_forecast_line/readme/DESCRIPTION.rst
+++ b/project_forecast_line/readme/DESCRIPTION.rst
@@ -1,3 +1,27 @@
 This module allows to plan your resources using forecast lines.
-For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will "consume" the work time capacity of the employees.
-Forecast lines also come in two states "forecast" or "confirmed", depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type "forecast", whereas tasks for project which are in a running state create lines with type "confirmed".
+
+For each employee of the company, the module will generate forecast line
+records with a positive capacity based on their working time schedules. Then,
+tasks assigned to employees will generate forecast lines with a negative
+capacity which will "consume" the work time capacity of the employees.
+
+The idea is that you can then see the work capacity and scheduled work of
+people by summing the "forecasts" per time period. If you have more resources
+(positive forecast) than work (negative forecast) you will have a positive net
+sum. Otherwise you are in trouble and need to recruit or reschedule your
+work. Another way to use the report is checking when the work capacity of a
+department becomes positive (or high enough) in order to provide you potential
+customers with an estimate of when a project would be able to start.
+
+Forecast lines also come in two states "forecast" or "confirmed", depending on
+whether the consumption is confirmed or not. For instance, holidays requests
+and sales quotation lines create lines of type "forecast", whereas tasks for
+project which are in a running state create lines with type "confirmed".
+
+To get the best experience using the Forecast application you may want to install:
+
+* project_forecast_line_holidays_public module which takes public holidays into
+  account during forecast lines creation
+
+* project_forecast_line_bokeh_chart module which improves the reports of
+  project_forecast_line module by using the bokeh widget available in OCA/web
diff --git a/project_forecast_line/readme/USAGE.rst b/project_forecast_line/readme/USAGE.rst
new file mode 100644
index 0000000000..da1496b3d8
--- /dev/null
+++ b/project_forecast_line/readme/USAGE.rst
@@ -0,0 +1,62 @@
+Forecast lines have the following data:
+
+* Forecast hours: it is positive for resources (employees) and negative for
+  things which consume time (project tasks, for instance)
+
+* From and To date which are the beginning and ending of the period of the
+  capacity
+
+* Consolidated forecast: this is a computed field, which is computed as follows:
+
+  * for costs (project tasks for instance) we take the absolute value of the
+    forecast hours (so it is a positive number)
+
+  * for resources (employee capacity for a period), we take the capacity and
+    substract all the costs for that employee on the same period. So it will be
+    positive if the employee still has some free time, and negative if he is
+    overloaded with work.
+
+  * this consolidated forecast is currently converted to days to ease
+    readability of the forecast report
+
+
+Objects creating forecast lines:
+
+* employees with a forecast role will create forecast line with a positive
+  capacity and type "confirmed" for each day on which they work. This
+  information comes from their work calendar, and the different roles that are
+  linked to the employee.
+
+* draft sale orders (if enabled in the settings) will create forecast lines of
+  type "forecast" for each sale order line having a product with a forecast
+  role and start and end dates. The forecast hours are negative
+
+* confirmed sale orders don't create forecast lines. This is handled by the
+  tasks created at the confirmation of the sale order
+
+* project tasks create forecast lines if they have a linked role and planned start/end
+  dates. The type of the line will depend on the related project's stage. The
+  `forecast_hours` field is based on the remaining time of the task, which is spread
+  on the work days of the planned start and end date of the task. If the
+  current date is in the middle of the planned duration of the task, it is used
+  as the start date. If the planned end date is in the past the task does not
+  generate forecast lines (and you need to fix your planning). In case multiple
+  employees are assigned to the task the forecast is split equally between
+  them.
+
+* holiday requests create negative forecast lines with type "forecast" when
+  they are pending manager validation.
+
+* Validated holiday requests do not generate forecast lines, as they alter the
+  work calendar of the employee: the employee will not have a positive line
+  associated to his leave days.
+
+The creation of forecast lines is done either in real time when some actions
+are performed by the user (requesting leaves, updating the remaining time on a
+project task, timesheeting) and also via a cron that runs on a daily basis. The
+cron is required to cleanup lines related to dates in the past and to recompute
+the lines related to project tasks by computing the ratio of remaing time on
+the tasks on the remaining days, for tasks which are in progress. So, to start
+using consolidated forecast report you first need to set everything mentioned
+in Usage section. Then, probably run Forecast recomputation cron manually from
+Scheduled Actions or wait till cron creates records.
diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html
index 9e96d47fcd..29058b0689 100644
--- a/project_forecast_line/static/description/index.html
+++ b/project_forecast_line/static/description/index.html
@@ -3,7 +3,7 @@
 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
-<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
+<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
 <title>Project Forecast Lines</title>
 <style type="text/css">
 
@@ -368,23 +368,82 @@ <h1 class="title">Project Forecast Lines</h1>
 !! changes will be overwritten.                   !!
 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
 <p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/project/tree/15.0/project_forecast_line"><img alt="OCA/project" src="https://img.shields.io/badge/github-OCA%2Fproject-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/140/15.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
-<p>This module allows to plan your resources using forecast lines.
-For each employee of the company, the module will generate forecast line records with a positive capacity based on their working time schedules. Then, tasks assigned to employees will generate forecast lines with a negative capacity which will “consume” the work time capacity of the employees.
-Forecast lines also come in two states “forecast” or “confirmed”, depending on whether the consumption is confirmed or not. For instance, holidays requests and sales quotation lines create lines of type “forecast”, whereas tasks for project which are in a running state create lines with type “confirmed”.</p>
+<p>This module allows to plan your resources using forecast lines.</p>
+<p>For each employee of the company, the module will generate forecast line
+records with a positive capacity based on their working time schedules. Then,
+tasks assigned to employees will generate forecast lines with a negative
+capacity which will “consume” the work time capacity of the employees.</p>
+<p>The idea is that you can then see the work capacity and scheduled work of
+people by summing the “forecasts” per time period. If you have more resources
+(positive forecast) than work (negative forecast) you will have a positive net
+sum. Otherwise you are in trouble and need to recruit or reschedule your
+work. Another way to use the report is checking when the work capacity of a
+department becomes positive (or high enough) in order to provide you potential
+customers with an estimate of when a project would be able to start.</p>
+<p>Forecast lines also come in two states “forecast” or “confirmed”, depending on
+whether the consumption is confirmed or not. For instance, holidays requests
+and sales quotation lines create lines of type “forecast”, whereas tasks for
+project which are in a running state create lines with type “confirmed”.</p>
 <p><strong>Table of contents</strong></p>
 <div class="contents local topic" id="contents">
 <ul class="simple">
-<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li>
-<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul>
-<li><a class="reference internal" href="#authors" id="id3">Authors</a></li>
-<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li>
-<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li>
+<li><a class="reference internal" href="#usage" id="id1">Usage</a></li>
+<li><a class="reference internal" href="#bug-tracker" id="id2">Bug Tracker</a></li>
+<li><a class="reference internal" href="#credits" id="id3">Credits</a><ul>
+<li><a class="reference internal" href="#authors" id="id4">Authors</a></li>
+<li><a class="reference internal" href="#contributors" id="id5">Contributors</a></li>
+<li><a class="reference internal" href="#maintainers" id="id6">Maintainers</a></li>
 </ul>
 </li>
 </ul>
 </div>
+<div class="section" id="usage">
+<h1><a class="toc-backref" href="#id1">Usage</a></h1>
+<p>Forecast lines have the following data:</p>
+<ul class="simple">
+<li>Forecast hours: it is positive for resources (employees) and negative for
+things which consume time</li>
+<li>From and To date which are the beginning and ending of the period of the
+capacity</li>
+<li>Consolidated forecast: this is a computed field, which is computed as follows:<ul>
+<li>for costs (project tasks for instance) we take the absolute value of the
+forecast hours (so it is a positive number)</li>
+<li>for resources (employee capacity for a period), we take the capacity and
+substract all the costs for that employee on the same period. So it will be
+positive if the employee still has some free time, and negative if he is
+overloaded with work.</li>
+</ul>
+</li>
+</ul>
+<p>Objects creating forecast lines:</p>
+<ul class="simple">
+<li>employees with a forecast role will create forecast line with a positive
+capacity and type “confirmed” for each day on which they work. This
+information comes from their work calendar, and the different roles that are
+linked to the employee.</li>
+<li>draft sale orders (if enabled in the settings) will create forecast lines of
+type “forecast” for each sale order line having a product with a forecast
+role and start and end dates. The forecast hours are negative</li>
+<li>confirmed sale orders don’t create forecast lines. This is handled by the
+tasks created at the confirmation of the sale order</li>
+<li>project tasks create forecast lines if they have a linked role and start/end
+date. The type of the line will depend on the related project’s stage. The
+forecast quantity is based on the remaining time of the task, which is spread
+on the work days of the planned start and end date of the task. If the
+current date is in the middle of the planned duration of the task, it is used
+as the start date. If the planned end date is in the past the task does not
+generate forecast lines (and you need to fix your planning). In case multiple
+employees are assigned to the task the forecast is split equally between
+them.</li>
+<li>holiday requests create negative forecast lines with type “forecast” when
+they are pending manager validation.</li>
+<li>Validated holiday requests do not generate forecast lines, as they alter the
+work calendar of the employee: the employee will not have a positive line
+associated to his leave days.</li>
+</ul>
+</div>
 <div class="section" id="bug-tracker">
-<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1>
+<h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>
 <p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/project/issues">GitHub Issues</a>.
 In case of trouble, please check there if your issue has already been reported.
 If you spotted it first, help us smashing it by providing a detailed and welcomed
@@ -392,22 +451,22 @@ <h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1>
 <p>Do not contact contributors directly about support or help with technical issues.</p>
 </div>
 <div class="section" id="credits">
-<h1><a class="toc-backref" href="#id2">Credits</a></h1>
+<h1><a class="toc-backref" href="#id3">Credits</a></h1>
 <div class="section" id="authors">
-<h2><a class="toc-backref" href="#id3">Authors</a></h2>
+<h2><a class="toc-backref" href="#id4">Authors</a></h2>
 <ul class="simple">
 <li>Camptocamp SA</li>
 </ul>
 </div>
 <div class="section" id="contributors">
-<h2><a class="toc-backref" href="#id4">Contributors</a></h2>
+<h2><a class="toc-backref" href="#id5">Contributors</a></h2>
 <ul class="simple">
 <li>Alexandre Fayolle &lt;<a class="reference external" href="mailto:alexandre.fayolle&#64;camptocamp.com">alexandre.fayolle&#64;camptocamp.com</a>&gt;</li>
 <li>Maksym Yankin &lt;<a class="reference external" href="mailto:maksym.yankin&#64;camptocamp.com">maksym.yankin&#64;camptocamp.com</a>&gt;</li>
 </ul>
 </div>
 <div class="section" id="maintainers">
-<h2><a class="toc-backref" href="#id5">Maintainers</a></h2>
+<h2><a class="toc-backref" href="#id6">Maintainers</a></h2>
 <p>This module is maintained by the OCA.</p>
 <a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
 <p>OCA, or the Odoo Community Association, is a nonprofit organization whose

From db498daa97a635ed390b076e01041b285fd3430e Mon Sep 17 00:00:00 2001
From: OCA-git-bot <oca-git-bot@odoo-community.org>
Date: Wed, 31 Aug 2022 11:55:35 +0000
Subject: [PATCH 05/33] [UPD] README.rst

---
 project_forecast_line/README.rst              | 28 +++++++++++++++----
 .../static/description/index.html             | 28 +++++++++++++++----
 2 files changed, 46 insertions(+), 10 deletions(-)

diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst
index 87c7d75b27..72d80fd34f 100644
--- a/project_forecast_line/README.rst
+++ b/project_forecast_line/README.rst
@@ -45,6 +45,13 @@ whether the consumption is confirmed or not. For instance, holidays requests
 and sales quotation lines create lines of type "forecast", whereas tasks for
 project which are in a running state create lines with type "confirmed".
 
+To get the best experience using the Forecast application you may want to install:
+
+* project_forecast_line_holidays_public module which takes public holidays into
+  account during forecast lines creation
+
+* project_forecast_line_bokeh_chart module which improves the reports of
+  project_forecast_line module by using the bokeh widget available in OCA/web
 
 **Table of contents**
 
@@ -57,7 +64,7 @@ Usage
 Forecast lines have the following data:
 
 * Forecast hours: it is positive for resources (employees) and negative for
-  things which consume time
+  things which consume time (project tasks, for instance)
 
 * From and To date which are the beginning and ending of the period of the
   capacity
@@ -72,6 +79,9 @@ Forecast lines have the following data:
     positive if the employee still has some free time, and negative if he is
     overloaded with work.
 
+  * this consolidated forecast is currently converted to days to ease
+    readability of the forecast report
+
 
 Objects creating forecast lines:
 
@@ -87,9 +97,9 @@ Objects creating forecast lines:
 * confirmed sale orders don't create forecast lines. This is handled by the
   tasks created at the confirmation of the sale order
 
-* project tasks create forecast lines if they have a linked role and start/end
-  date. The type of the line will depend on the related project's stage. The
-  forecast quantity is based on the remaining time of the task, which is spread
+* project tasks create forecast lines if they have a linked role and planned start/end
+  dates. The type of the line will depend on the related project's stage. The
+  `forecast_hours` field is based on the remaining time of the task, which is spread
   on the work days of the planned start and end date of the task. If the
   current date is in the middle of the planned duration of the task, it is used
   as the start date. If the planned end date is in the past the task does not
@@ -104,7 +114,15 @@ Objects creating forecast lines:
   work calendar of the employee: the employee will not have a positive line
   associated to his leave days.
 
-
+The creation of forecast lines is done either in real time when some actions
+are performed by the user (requesting leaves, updating the remaining time on a
+project task, timesheeting) and also via a cron that runs on a daily basis. The
+cron is required to cleanup lines related to dates in the past and to recompute
+the lines related to project tasks by computing the ratio of remaing time on
+the tasks on the remaining days, for tasks which are in progress. So, to start
+using consolidated forecast report you first need to set everything mentioned
+in Usage section. Then, probably run Forecast recomputation cron manually from
+Scheduled Actions or wait till cron creates records.
 
 Bug Tracker
 ===========
diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html
index 29058b0689..4eefcd70df 100644
--- a/project_forecast_line/static/description/index.html
+++ b/project_forecast_line/static/description/index.html
@@ -3,7 +3,7 @@
 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
-<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
+<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
 <title>Project Forecast Lines</title>
 <style type="text/css">
 
@@ -384,6 +384,13 @@ <h1 class="title">Project Forecast Lines</h1>
 whether the consumption is confirmed or not. For instance, holidays requests
 and sales quotation lines create lines of type “forecast”, whereas tasks for
 project which are in a running state create lines with type “confirmed”.</p>
+<p>To get the best experience using the Forecast application you may want to install:</p>
+<ul class="simple">
+<li>project_forecast_line_holidays_public module which takes public holidays into
+account during forecast lines creation</li>
+<li>project_forecast_line_bokeh_chart module which improves the reports of
+project_forecast_line module by using the bokeh widget available in OCA/web</li>
+</ul>
 <p><strong>Table of contents</strong></p>
 <div class="contents local topic" id="contents">
 <ul class="simple">
@@ -402,7 +409,7 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 <p>Forecast lines have the following data:</p>
 <ul class="simple">
 <li>Forecast hours: it is positive for resources (employees) and negative for
-things which consume time</li>
+things which consume time (project tasks, for instance)</li>
 <li>From and To date which are the beginning and ending of the period of the
 capacity</li>
 <li>Consolidated forecast: this is a computed field, which is computed as follows:<ul>
@@ -412,6 +419,8 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 substract all the costs for that employee on the same period. So it will be
 positive if the employee still has some free time, and negative if he is
 overloaded with work.</li>
+<li>this consolidated forecast is currently converted to days to ease
+readability of the forecast report</li>
 </ul>
 </li>
 </ul>
@@ -426,9 +435,9 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 role and start and end dates. The forecast hours are negative</li>
 <li>confirmed sale orders don’t create forecast lines. This is handled by the
 tasks created at the confirmation of the sale order</li>
-<li>project tasks create forecast lines if they have a linked role and start/end
-date. The type of the line will depend on the related project’s stage. The
-forecast quantity is based on the remaining time of the task, which is spread
+<li>project tasks create forecast lines if they have a linked role and planned start/end
+dates. The type of the line will depend on the related project’s stage. The
+<cite>forecast_hours</cite> field is based on the remaining time of the task, which is spread
 on the work days of the planned start and end date of the task. If the
 current date is in the middle of the planned duration of the task, it is used
 as the start date. If the planned end date is in the past the task does not
@@ -441,6 +450,15 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 work calendar of the employee: the employee will not have a positive line
 associated to his leave days.</li>
 </ul>
+<p>The creation of forecast lines is done either in real time when some actions
+are performed by the user (requesting leaves, updating the remaining time on a
+project task, timesheeting) and also via a cron that runs on a daily basis. The
+cron is required to cleanup lines related to dates in the past and to recompute
+the lines related to project tasks by computing the ratio of remaing time on
+the tasks on the remaining days, for tasks which are in progress. So, to start
+using consolidated forecast report you first need to set everything mentioned
+in Usage section. Then, probably run Forecast recomputation cron manually from
+Scheduled Actions or wait till cron creates records.</p>
 </div>
 <div class="section" id="bug-tracker">
 <h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>

From 39abf09ad36f315ed3e137910e2b3f9d41315528 Mon Sep 17 00:00:00 2001
From: OCA-git-bot <oca-git-bot@odoo-community.org>
Date: Wed, 31 Aug 2022 11:55:36 +0000
Subject: [PATCH 06/33] project_forecast_line 15.0.1.0.1

---
 project_forecast_line/__manifest__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
index de8349b15e..961c7616ff 100644
--- a/project_forecast_line/__manifest__.py
+++ b/project_forecast_line/__manifest__.py
@@ -3,7 +3,7 @@
 {
     "name": "Project Forecast Lines",
     "summary": "Project Forecast Lines",
-    "version": "15.0.1.0.0",
+    "version": "15.0.1.0.1",
     "author": "Camptocamp SA, Odoo Community Association (OCA)",
     "license": "AGPL-3",
     "category": "Project",

From 733013ed5d91e02bf5d3950ad51aa605788e0a2e Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Wed, 31 Aug 2022 17:13:22 +0200
Subject: [PATCH 07/33] [FIX] project_forecast_line consolidated capacity

in some cases, we could have capacity consumed by a task which was not matching
a work capacity line in the period because of a faulty optimisation we were making
which skipped the creation of lines with a capacity of 0 -> then we had no line on which
to compute the negative consolidated capacity.

We remove the optimisation to fix this case and show the problematic periods
in the consolidated capacity graphs
---
 project_forecast_line/models/forecast_line.py | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index 50d54f2da8..22e7a38a64 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -245,10 +245,11 @@ def _split_per_period(
                 resource,
                 calendar,
             )
-            if period_forecast == 0:
-                # don"t create forecast lines with a forecast of 0
-                curr_date = next_date
-                continue
+            # note we do create lines even if the period_forecast is 0, as this
+            # ensures that consolidated capacity can be computed: if there is
+            # no line for a day when the employee does not work, but for some
+            # reason there is a need on that day, we need the 0 capacity line
+            # to compute the negative consolidated capacity.
             period_forecast *= daily_forecast
             period_cost = period_forecast * unit_cost
             updates = {

From 5cafbbfb4e16ff51f7598c80ff2b15bc0a4b66c8 Mon Sep 17 00:00:00 2001
From: OCA-git-bot <oca-git-bot@odoo-community.org>
Date: Fri, 2 Sep 2022 14:53:01 +0000
Subject: [PATCH 08/33] project_forecast_line 15.0.1.0.2

---
 project_forecast_line/__manifest__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
index 961c7616ff..037edf189a 100644
--- a/project_forecast_line/__manifest__.py
+++ b/project_forecast_line/__manifest__.py
@@ -3,7 +3,7 @@
 {
     "name": "Project Forecast Lines",
     "summary": "Project Forecast Lines",
-    "version": "15.0.1.0.1",
+    "version": "15.0.1.0.2",
     "author": "Camptocamp SA, Odoo Community Association (OCA)",
     "license": "AGPL-3",
     "category": "Project",

From 03ebd53209ea14436117746f8879d46a5d309c84 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Tue, 30 Aug 2022 11:39:39 +0300
Subject: [PATCH 09/33] [15.0][FIX] project_forecast_line: take roles into
 account on calc

---
 project_forecast_line/models/forecast_line.py |  20 ++-
 project_forecast_line/models/project_task.py  |   5 +-
 .../tests/test_forecast_line.py               | 152 +++++++++++++++++-
 3 files changed, 165 insertions(+), 12 deletions(-)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index 22e7a38a64..72026960f6 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -88,12 +88,15 @@ class ForecastLine(models.Model):
     @api.depends("employee_id", "date_from", "type", "res_model")
     def _compute_employee_forecast_line_id(self):
         employees = self.mapped("employee_id")
+        main_roles = employees.mapped("main_role_id")
         date_froms = self.mapped("date_from")
         date_tos = self.mapped("date_to")
+        forecast_roles = self.mapped("forecast_role_id") | main_roles
         if employees:
             lines = self.search(
                 [
                     ("employee_id", "in", employees.ids),
+                    ("forecast_role_id", "in", forecast_roles.ids),
                     ("res_model", "=", "hr.employee.forecast.role"),
                     ("date_from", ">=", min(date_froms)),
                     ("date_to", "<=", max(date_tos)),
@@ -104,12 +107,23 @@ def _compute_employee_forecast_line_id(self):
             lines = self.env["forecast.line"]
         capacities = {}
         for line in lines:
-            capacities[(line.employee_id.id, line.date_from)] = line.id
+            capacities[
+                (line.employee_id.id, line.date_from, line.forecast_role_id.id)
+            ] = line.id
         for rec in self:
             if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role":
-                rec.employee_resource_forecast_line_id = capacities.get(
-                    (rec.employee_id.id, rec.date_from), False
+                resource_forecast_line = capacities.get(
+                    (rec.employee_id.id, rec.date_from, rec.forecast_role_id.id), False
                 )
+                if resource_forecast_line:
+                    rec.employee_resource_forecast_line_id = resource_forecast_line
+                else:
+                    # if we didn't find a forecast line with a matching role
+                    # we get forecast line with the main role of the employee
+                    main_role_id = rec.employee_id.main_role_id
+                    rec.employee_resource_forecast_line_id = capacities.get(
+                        (rec.employee_id.id, rec.date_from, main_role_id.id), False
+                    )
             else:
                 rec.employee_resource_forecast_line_id = False
 
diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py
index 7e4df82a15..0b3cb91d16 100644
--- a/project_forecast_line/models/project_task.py
+++ b/project_forecast_line/models/project_task.py
@@ -18,9 +18,9 @@ class ProjectTask(models.Model):
     def create(self, vals_list):
         # compatibility with fields from project_enterprise
         for vals in vals_list:
-            if "planned_date_begin" in vals:
+            if vals.get("planned_date_begin"):
                 vals["forecast_date_planned_start"] = vals["planned_date_begin"]
-            if "planned_date_end" in vals:
+            if vals.get("planned_date_end"):
                 vals["forecast_date_planned_end"] = vals["planned_date_end"]
         tasks = super().create(vals_list)
         tasks._update_forecast_lines()
@@ -94,6 +94,7 @@ def _update_forecast_lines(self):
                     # are not generating forecast lines from SO
                     _logger.info("skip task %s: draft sale")
                     continue
+
             if (
                 not task.forecast_date_planned_start
                 or not task.forecast_date_planned_end
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index 9a1a6a9d98..34fbb1bfd3 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -517,7 +517,7 @@ def test_task_forecast_lines_consolidated_forecast(self):
             self.assertEqual(len(forecast), 1)
             # using assertEqual on purpose here
             self.assertEqual(forecast.forecast_hours, -6.0)
-            self.assertEqual(round(forecast.consolidated_forecast, 5), 0.75000)
+            self.assertAlmostEqual(forecast.consolidated_forecast, 0.75)
             self.assertEqual(
                 forecast.employee_resource_forecast_line_id.consolidated_forecast,
                 0.25,
@@ -603,10 +603,148 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
                 forecast1.employee_resource_forecast_line_id,
                 forecast2.employee_resource_forecast_line_id,
             )
-            self.assertEqual(
-                round(
-                    forecast1.employee_resource_forecast_line_id.consolidated_forecast,
-                    5,
-                ),
-                -0.75000,
+            self.assertAlmostEqual(
+                forecast1.employee_resource_forecast_line_id.consolidated_forecast,
+                -0.75,
             )
+
+    def test_task_forecast_lines_employee_different_roles(self):
+        """
+        Test forecast lines when employee has different roles.
+
+        Employee has 2 forecast_role_id: consultant 75% and project manager 25%,
+        working 8h per day (standard calendar).
+        Create a task with forecast role consultant, with remaining time = 8h
+        and a scheduled period starting and ending on the same day (today for instance).
+        Assign this task to the user.
+
+        Expected: for the user, on today, 3 forecast lines.
+
+        res_model	                forecast_role_id  forecast_hours consolidated_forecast
+        project.task	            consultant	         -8	             1 (in days)
+        hr.employee.forecast.role	consultant	          6	            -0.25 (in days)
+        hr.employee.forecast.role	project manager	      2	             0.25 (in days)
+
+        """
+        self.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": self.employee_consultant.id,
+                "role_id": self.role_pm.id,
+                "date_start": "2022-01-01",
+                "rate": 25,
+                "sequence": 1,
+            }
+        )
+        consultant_role = self.env["hr.employee.forecast.role"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("role_id", "=", self.role_consultant.id),
+            ]
+        )
+        consultant_role.rate = 75
+        project = self.env["project.project"].create({"name": "TestProjectDiffRoles"})
+        # set project in stage "in progress" to get confirmed forecast
+        project.stage_id = self.env.ref("project.project_project_stage_1")
+        task = self.env["project.task"].create(
+            {
+                "name": "TaskDiffRoles",
+                "project_id": project.id,
+                "forecast_role_id": self.role_consultant.id,
+                "forecast_date_planned_start": date.today(),
+                "forecast_date_planned_end": date.today(),
+                "planned_hours": 8,
+            }
+        )
+        task.user_ids = self.user_consultant
+        task_forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
+        self.assertEqual(len(task_forecast), 1)
+        # using assertEqual on purpose here
+        self.assertEqual(task_forecast.forecast_hours, -8.0)
+        self.assertEqual(task_forecast.consolidated_forecast, 1.0)
+        employee_forecast = self.env["forecast.line"].search(
+            [("employee_id", "=", self.employee_consultant.id)]
+        )
+        # we can take first line to check as forecast values are equal
+        forecast_consultant = employee_forecast.filtered(
+            lambda l: l.res_model == "hr.employee.forecast.role"
+            and l.forecast_role_id == self.role_consultant
+        )[0]
+        self.assertEqual(forecast_consultant.forecast_hours, 6.0)
+        self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25)
+        forecast_pm = employee_forecast.filtered(
+            lambda l: l.res_model == "hr.employee.forecast.role"
+            and l.forecast_role_id == self.role_pm
+        )[0]
+        self.assertEqual(forecast_pm.forecast_hours, 2.0)
+        self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)
+
+    def test_task_forecast_lines_employee_main_role(self):
+        """
+        Test forecast lines when employee has different roles
+        and different from employee's role is assigned to the task.
+
+        Employee has 2 forecast_role_id: consultant 75% and project manager 25%,
+        working 8h per day (standard calendar).
+        Create a task with forecast role developer, with remaining time = 8h
+        and a scheduled period starting and ending on the same day (today for instance).
+        Assign this task to the user.
+
+        Expected: for the user, on today, 3 forecast lines.
+
+        res_model	                forecast_role_id  forecast_hours consolidated_forecast
+        project.task	            consultant	         -8	             1 (in days)
+        hr.employee.forecast.role	consultant	          6	            -0.25 (in days)
+        hr.employee.forecast.role	project manager	      2	             0.25 (in days)
+
+        """
+        self.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": self.employee_consultant.id,
+                "role_id": self.role_pm.id,
+                "date_start": "2022-01-01",
+                "rate": 25,
+                "sequence": 1,
+            }
+        )
+        consultant_role = self.env["hr.employee.forecast.role"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("role_id", "=", self.role_consultant.id),
+            ]
+        )
+        consultant_role.rate = 75
+        project = self.env["project.project"].create({"name": "TestProjectDiffRoles"})
+        # set project in stage "in progress" to get confirmed forecast
+        project.stage_id = self.env.ref("project.project_project_stage_1")
+        task = self.env["project.task"].create(
+            {
+                "name": "TaskDiffRoles",
+                "project_id": project.id,
+                "forecast_role_id": self.role_developer.id,
+                "forecast_date_planned_start": date.today(),
+                "forecast_date_planned_end": date.today(),
+                "planned_hours": 8,
+            }
+        )
+        task.user_ids = self.user_consultant
+        task_forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
+        self.assertEqual(len(task_forecast), 1)
+        # using assertEqual on purpose here
+        self.assertEqual(task_forecast.forecast_hours, -8.0)
+        self.assertEqual(task_forecast.consolidated_forecast, 1.0)
+        employee_forecast = self.env["forecast.line"].search(
+            [("employee_id", "=", self.employee_consultant.id)]
+        )
+        # we can take first line to check as forecast values are equal
+        forecast_consultant = employee_forecast.filtered(
+            lambda l: l.res_model == "hr.employee.forecast.role"
+            and l.forecast_role_id == self.role_consultant
+        )[0]
+        self.assertEqual(forecast_consultant.forecast_hours, 6.0)
+        self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25)
+        forecast_pm = employee_forecast.filtered(
+            lambda l: l.res_model == "hr.employee.forecast.role"
+            and l.forecast_role_id == self.role_pm
+        )[0]
+        self.assertEqual(forecast_pm.forecast_hours, 2.0)
+        self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)

From 8794f99cba0d20b7c108c75526089263ed2ce099 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Mon, 5 Sep 2022 11:03:52 +0300
Subject: [PATCH 10/33] [15.0][IMP] project_forecast_line: setting to control
 consumption states

---
 project_forecast_line/models/forecast_line.py    | 10 +++++++++-
 project_forecast_line/models/res_company.py      | 16 ++++++++++++++++
 .../models/res_config_settings.py                |  4 +++-
 .../views/res_config_settings_views.xml          | 11 +++++++++++
 4 files changed, 39 insertions(+), 2 deletions(-)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index 72026960f6..11cfc325a3 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -85,8 +85,13 @@ class ForecastLine(models.Model):
         "forecast.line", "employee_resource_forecast_line_id"
     )
 
+    def _get_consumption_states(self):
+        consumption_states = self.env.company.forecast_consumption_states
+        return tuple(consumption_states.split("_"))
+
     @api.depends("employee_id", "date_from", "type", "res_model")
     def _compute_employee_forecast_line_id(self):
+        consumption_states = self._get_consumption_states()
         employees = self.mapped("employee_id")
         main_roles = employees.mapped("main_role_id")
         date_froms = self.mapped("date_from")
@@ -111,7 +116,10 @@ def _compute_employee_forecast_line_id(self):
                 (line.employee_id.id, line.date_from, line.forecast_role_id.id)
             ] = line.id
         for rec in self:
-            if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role":
+            if (
+                rec.type in consumption_states
+                and rec.res_model != "hr.employee.forecast.role"
+            ):
                 resource_forecast_line = capacities.get(
                     (rec.employee_id.id, rec.date_from, rec.forecast_role_id.id), False
                 )
diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py
index 4d88fee87d..c3a41c0661 100644
--- a/project_forecast_line/models/res_company.py
+++ b/project_forecast_line/models/res_company.py
@@ -14,6 +14,22 @@ class ResCompany(models.Model):
     forecast_line_horizon = fields.Integer(
         help="Number of month for the forecast planning", default=12
     )
+    forecast_consumption_states = fields.Selection(
+        selection=[
+            ("confirmed", "Compute consolidated forecast for lines of type confirmed"),
+            (
+                "forecast_confirmed",
+                "Include lines of type forecast in consolidated forecast computation",
+            ),
+        ],
+        string="Consumption state rules",
+        help="For instance, holidays requests and sales quotation lines"
+        "create lines of type forecast and won't be taken into account"
+        "during consolidated forecast computation, whereas tasks for project"
+        "which are in a running state create lines with type confirmed"
+        "and will be used to compute consolidated forecast.",
+        default="confirmed",
+    )
 
     def write(self, values):
         res = super().write(values)
diff --git a/project_forecast_line/models/res_config_settings.py b/project_forecast_line/models/res_config_settings.py
index 4ac43649ec..4c7cc90534 100644
--- a/project_forecast_line/models/res_config_settings.py
+++ b/project_forecast_line/models/res_config_settings.py
@@ -12,7 +12,9 @@ class ResConfigSettings(models.TransientModel):
     forecast_line_horizon = fields.Integer(
         related="company_id.forecast_line_horizon", readonly=False
     )
-
+    forecast_consumption_states = fields.Selection(
+        related="company_id.forecast_consumption_states", readonly=False
+    )
     group_forecast_line_on_quotation = fields.Boolean(
         "Forecast Line on Quotations",
         implied_group="project_forecast_line.group_forecast_line_on_quotation",
diff --git a/project_forecast_line/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml
index e273587afb..61b15942a2 100644
--- a/project_forecast_line/views/res_config_settings_views.xml
+++ b/project_forecast_line/views/res_config_settings_views.xml
@@ -50,6 +50,17 @@
                                             class="o_field_integer o_field_number o_field_widget o_input oe_inline col-lg-2"
                                         />
                                     </div>
+                                    <div class="mt8">
+                                        <label for="forecast_consumption_states" />
+                                        <div class="text-muted">
+                                            Select the states for which the consumption is confirmed or not
+                                        </div>
+                                        <field
+                                            name="forecast_consumption_states"
+                                            required="1"
+                                            class="o_light_label"
+                                        />
+                                    </div>
                                 </div>
                             </div>
                         </div>

From cb60cd8d7a1e1ebe3fe7833442130651c2ab8dc1 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Mon, 5 Sep 2022 16:01:32 +0200
Subject: [PATCH 11/33] bump version after clicking on merge by mistake

---
 project_forecast_line/__manifest__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
index 037edf189a..7f36b8653a 100644
--- a/project_forecast_line/__manifest__.py
+++ b/project_forecast_line/__manifest__.py
@@ -3,7 +3,7 @@
 {
     "name": "Project Forecast Lines",
     "summary": "Project Forecast Lines",
-    "version": "15.0.1.0.2",
+    "version": "15.0.1.0.3",
     "author": "Camptocamp SA, Odoo Community Association (OCA)",
     "license": "AGPL-3",
     "category": "Project",

From f3e3d88762f263158e2812e4c18a9c1c910faf7c Mon Sep 17 00:00:00 2001
From: oca-ci <oca-ci@odoo-community.org>
Date: Mon, 5 Sep 2022 14:05:17 +0000
Subject: [PATCH 12/33] [UPD] Update project_forecast_line.pot

---
 .../i18n/project_forecast_line.pot            | 31 +++++++++++++++++++
 1 file changed, 31 insertions(+)

diff --git a/project_forecast_line/i18n/project_forecast_line.pot b/project_forecast_line/i18n/project_forecast_line.pot
index 8d1a46263f..ebf02c37ed 100644
--- a/project_forecast_line/i18n/project_forecast_line.pot
+++ b/project_forecast_line/i18n/project_forecast_line.pot
@@ -34,6 +34,11 @@ msgstr ""
 msgid "Company"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__confirmed
+msgid "Compute consolidated forecast for lines of type confirmed"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model,name:project_forecast_line.model_res_config_settings
 msgid "Config Settings"
@@ -56,6 +61,12 @@ msgstr ""
 msgid "Consolidated Forecast"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_consumption_states
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_consumption_states
+msgid "Consumption state rules"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost
 msgid "Cost"
@@ -178,6 +189,16 @@ msgstr ""
 msgid "Employee role for task matching"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_consumption_states
+#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_consumption_states
+msgid ""
+"For instance, holidays requests and sales quotation linescreate lines of "
+"type forecast and won't be taken into accountduring consolidated forecast "
+"computation, whereas tasks for projectwhich are in a running state create "
+"lines with type confirmedand will be used to compute consolidated forecast."
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines
 #: model:ir.model,name:project_forecast_line.model_forecast_line
@@ -279,6 +300,11 @@ msgstr ""
 msgid "ID"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__forecast_confirmed
+msgid "Include lines of type forecast in consolidated forecast computation"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model,name:project_forecast_line.model_hr_job
 msgid "Job Position"
@@ -423,6 +449,11 @@ msgstr ""
 msgid "Sales Order Line"
 msgstr ""
 
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Select the states for which the consumption is confirmed or not"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence
 msgid "Sequence"

From 4ca2225c2254db7a1e113b38f933add31b81f594 Mon Sep 17 00:00:00 2001
From: OCA Transbot <transbot@odoo-community.org>
Date: Mon, 5 Sep 2022 14:05:32 +0000
Subject: [PATCH 13/33] Update translation files

Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: project-15.0/project-15.0-project_forecast_line
Translate-URL: https://translation.odoo-community.org/projects/project-15-0/project-15-0-project_forecast_line/
---
 project_forecast_line/i18n/fr.po | 31 +++++++++++++++++++++++++++++++
 1 file changed, 31 insertions(+)

diff --git a/project_forecast_line/i18n/fr.po b/project_forecast_line/i18n/fr.po
index 3da256fe38..2d563eb6ac 100644
--- a/project_forecast_line/i18n/fr.po
+++ b/project_forecast_line/i18n/fr.po
@@ -37,6 +37,11 @@ msgstr ""
 msgid "Company"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__confirmed
+msgid "Compute consolidated forecast for lines of type confirmed"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model,name:project_forecast_line.model_res_config_settings
 msgid "Config Settings"
@@ -59,6 +64,12 @@ msgstr ""
 msgid "Consolidated Forecast"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_company__forecast_consumption_states
+#: model:ir.model.fields,field_description:project_forecast_line.field_res_config_settings__forecast_consumption_states
+msgid "Consumption state rules"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model.fields,field_description:project_forecast_line.field_forecast_line__cost
 msgid "Cost"
@@ -181,6 +192,16 @@ msgstr ""
 msgid "Employee role for task matching"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields,help:project_forecast_line.field_res_company__forecast_consumption_states
+#: model:ir.model.fields,help:project_forecast_line.field_res_config_settings__forecast_consumption_states
+msgid ""
+"For instance, holidays requests and sales quotation linescreate lines of "
+"type forecast and won't be taken into accountduring consolidated forecast "
+"computation, whereas tasks for projectwhich are in a running state create "
+"lines with type confirmedand will be used to compute consolidated forecast."
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.actions.act_window,name:project_forecast_line.action_forecast_lines
 #: model:ir.model,name:project_forecast_line.model_forecast_line
@@ -282,6 +303,11 @@ msgstr ""
 msgid "ID"
 msgstr ""
 
+#. module: project_forecast_line
+#: model:ir.model.fields.selection,name:project_forecast_line.selection__res_company__forecast_consumption_states__forecast_confirmed
+msgid "Include lines of type forecast in consolidated forecast computation"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model,name:project_forecast_line.model_hr_job
 msgid "Job Position"
@@ -429,6 +455,11 @@ msgstr "Bon de commande"
 msgid "Sales Order Line"
 msgstr "Bon de commande"
 
+#. module: project_forecast_line
+#: model_terms:ir.ui.view,arch_db:project_forecast_line.res_config_settings_view_form
+msgid "Select the states for which the consumption is confirmed or not"
+msgstr ""
+
 #. module: project_forecast_line
 #: model:ir.model.fields,field_description:project_forecast_line.field_hr_employee_forecast_role__sequence
 msgid "Sequence"

From fde40b73d8cf25f82d1d3e430aa29b3f250848f2 Mon Sep 17 00:00:00 2001
From: ntsirintanis <ntsirintanis@therp.nl>
Date: Wed, 7 Sep 2022 09:50:49 +0200
Subject: [PATCH 14/33] [14.0][BACKPORT][WIP]project_forecast_line from 15.0 to
 14.0

---
 project_forecast_line/__manifest__.py         |  4 +-
 project_forecast_line/data/project_data.xml   | 19 ----------
 project_forecast_line/models/__init__.py      |  2 -
 project_forecast_line/models/hr_employee.py   |  5 +--
 project_forecast_line/models/hr_leave.py      |  2 +-
 .../models/project_project.py                 | 18 ---------
 .../models/project_project_stage.py           | 21 ----------
 project_forecast_line/models/project_task.py  | 27 ++++++-------
 .../tests/test_forecast_line.py               | 38 +++++++++----------
 .../odoo/addons/project_forecast_line         |  1 +
 setup/project_forecast_line/setup.py          |  6 +++
 11 files changed, 38 insertions(+), 105 deletions(-)
 delete mode 100644 project_forecast_line/data/project_data.xml
 delete mode 100644 project_forecast_line/models/project_project.py
 delete mode 100644 project_forecast_line/models/project_project_stage.py
 create mode 120000 setup/project_forecast_line/odoo/addons/project_forecast_line
 create mode 100644 setup/project_forecast_line/setup.py

diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
index 7f36b8653a..91b4ceb4b7 100644
--- a/project_forecast_line/__manifest__.py
+++ b/project_forecast_line/__manifest__.py
@@ -3,7 +3,7 @@
 {
     "name": "Project Forecast Lines",
     "summary": "Project Forecast Lines",
-    "version": "15.0.1.0.3",
+    "version": "14.0.1.0.3",
     "author": "Camptocamp SA, Odoo Community Association (OCA)",
     "license": "AGPL-3",
     "category": "Project",
@@ -18,10 +18,8 @@
         "views/forecast_role_views.xml",
         "views/product_views.xml",
         "views/project_task_views.xml",
-        "views/project_project_stage_views.xml",
         "views/res_config_settings_views.xml",
         "data/ir_cron.xml",
-        "data/project_data.xml",
     ],
     "installable": True,
     "application": True,
diff --git a/project_forecast_line/data/project_data.xml b/project_forecast_line/data/project_data.xml
deleted file mode 100644
index 1dd857c7af..0000000000
--- a/project_forecast_line/data/project_data.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8" ?>
-<odoo>
-    <!-- Project Stages -->
-    <record id="project.project_project_stage_0" model="project.project.stage">
-        <field name="forecast_line_type">forecast</field>
-    </record>
-
-    <record id="project.project_project_stage_1" model="project.project.stage">
-        <field name="forecast_line_type">confirmed</field>
-    </record>
-
-    <record id="project.project_project_stage_2" model="project.project.stage">
-        <field name="forecast_line_type" />
-    </record>
-
-    <record id="project.project_project_stage_3" model="project.project.stage">
-        <field name="forecast_line_type" />
-    </record>
-</odoo>
diff --git a/project_forecast_line/models/__init__.py b/project_forecast_line/models/__init__.py
index 9131303b85..972739fd50 100644
--- a/project_forecast_line/models/__init__.py
+++ b/project_forecast_line/models/__init__.py
@@ -10,5 +10,3 @@
 from . import account_analytic_line
 from . import res_config_settings
 from . import resource_calendar_leaves
-from . import project_project_stage
-from . import project_project
diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py
index 2c854eff82..60715aebd3 100644
--- a/project_forecast_line/models/hr_employee.py
+++ b/project_forecast_line/models/hr_employee.py
@@ -46,10 +46,7 @@ def _check_job_role(self, values):
             job = self.env["hr.job"].browse(new_job_id)
             if job.role_id and "role_ids" not in values:
                 values = values.copy()
-                values["role_ids"] = [
-                    fields.Command.clear(),
-                    fields.Command.create({"role_id": job.role_id.id}),
-                ]
+                values["role_ids"] = [(6, 0, job.role_id.ids)]
         return values
 
 
diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py
index 7c7c7c18d1..88f59c303c 100644
--- a/project_forecast_line/models/hr_leave.py
+++ b/project_forecast_line/models/hr_leave.py
@@ -69,7 +69,7 @@ def _recompute_forecast_lines(self, force_company_id=None):
             to_update = self.with_company(company).search(
                 [
                     ("date_to", ">=", today),
-                    ("employee_company_id", "=", company.id),
+                    ("employee_id.company_id", "=", company.id),
                 ]
             )
             to_update._update_forecast_lines()
diff --git a/project_forecast_line/models/project_project.py b/project_forecast_line/models/project_project.py
deleted file mode 100644
index 0daf904664..0000000000
--- a/project_forecast_line/models/project_project.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Copyright 2022 Camptocamp SA
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-from odoo import models
-
-
-class ProjectProject(models.Model):
-    _inherit = "project.project"
-
-    def _update_forecast_lines_trigger_fields(self):
-        return ["stage_id"]
-
-    def write(self, values):
-        res = super().write(values)
-        written_fields = list(values.keys())
-        trigger_fields = self._update_forecast_lines_trigger_fields()
-        if any(field in written_fields for field in trigger_fields):
-            self.task_ids._update_forecast_lines()
-        return res
diff --git a/project_forecast_line/models/project_project_stage.py b/project_forecast_line/models/project_project_stage.py
deleted file mode 100644
index 02eb6d63dc..0000000000
--- a/project_forecast_line/models/project_project_stage.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# Copyright 2022 Camptocamp SA
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
-from odoo import fields, models
-
-
-class ProjectProjectStage(models.Model):
-    _inherit = "project.project.stage"
-
-    forecast_line_type = fields.Selection(
-        [("forecast", "Forecast"), ("confirmed", "Confirmed")],
-        help="type of forecast lines created by the tasks of projects in that stage",
-    )
-
-    def write(self, values):
-        res = super().write(values)
-        if "forecast_line_type" in values:
-            projects = self.env["project.project"].search(
-                [("stage_id", "in", self.ids)]
-            )
-            projects.mapped("task_ids")._update_forecast_lines()
-        return res
diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py
index 0b3cb91d16..b868b4ef31 100644
--- a/project_forecast_line/models/project_task.py
+++ b/project_forecast_line/models/project_task.py
@@ -35,7 +35,7 @@ def _update_forecast_lines_trigger_fields(self):
             "remaining_hours",
             "name",
             "planned_time",
-            "user_ids",
+            "user_id",
         ]
 
     def write(self, values):
@@ -51,18 +51,17 @@ def write(self, values):
             self._update_forecast_lines()
         return res
 
-    @api.onchange("user_ids")
-    def onchange_user_ids(self):
+    @api.onchange("user_id")
+    def onchange_user_id(self):
         for task in self:
-            if not task.user_ids:
+            if not task.user_id:
                 continue
             if task.forecast_role_id:
                 continue
-            employees = task.mapped("user_ids.employee_id")
-            for employee in employees:
-                if employee.main_role_id:
-                    task.forecast_role_id = employee.main_role_id
-                    break
+            employee = task.user_id.employee_id
+            if employee.main_role_id:
+                task.forecast_role_id = employee.main_role_id
+                break
 
     def _update_forecast_lines(self):
         today = fields.Date.context_today(self)
@@ -73,15 +72,11 @@ def _update_forecast_lines(self):
             [("res_id", "in", self.ids), ("res_model", "=", self._name)]
         ).unlink()
         for task in self:
+            forecast_type = "forecast"
             if not task.forecast_role_id:
                 _logger.info("skip task %s: no forecast role", task)
                 continue
-            elif task.project_id.stage_id:
-                forecast_type = task.project_id.stage_id.forecast_line_type
-                if not forecast_type:
-                    _logger.info("skip task %s: no forecast for project state", task)
-                    continue  # closed / cancelled stage
-            elif task.sale_line_id:
+            if task.sale_line_id:
                 sale_state = task.sale_line_id.state
                 if sale_state == "cancel":
                     _logger.info("skip task %s: cancelled sale", task)
@@ -109,7 +104,7 @@ def _update_forecast_lines(self):
                 continue
             date_start = max(today, task.forecast_date_planned_start)
             date_end = max(today, task.forecast_date_planned_end)
-            employee_ids = task.mapped("user_ids.employee_id").ids
+            employee_ids = task.mapped("user_id.employee_id").ids
             if not employee_ids:
                 employee_ids = [False]
             _logger.debug(
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index 34fbb1bfd3..ad63c1edd2 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -4,10 +4,10 @@
 
 from freezegun import freeze_time
 
-from odoo.tests.common import Form, TransactionCase
+from odoo.tests.common import Form, SavepointCase
 
 
-class BaseForecastLineTest(TransactionCase):
+class BaseForecastLineTest(SavepointCase):
     @classmethod
     @freeze_time("2022-01-01")
     def setUpClass(cls):
@@ -57,7 +57,7 @@ def setUpClass(cls):
         cls.product_dev_tm = cls.env["product.product"].create(
             {
                 "name": "development time and material",
-                "detailed_type": "service",
+                "type": "service",
                 "service_tracking": "task_in_project",
                 "price": 95,
                 "standard_price": 75,
@@ -69,7 +69,7 @@ def setUpClass(cls):
         cls.product_consultant_tm = cls.env["product.product"].create(
             {
                 "name": "consultant time and material",
-                "detailed_type": "service",
+                "type": "service",
                 "service_tracking": "task_in_project",
                 "price": 100,
                 "standard_price": 80,
@@ -82,7 +82,7 @@ def setUpClass(cls):
         cls.product_pm_tm = cls.env["product.product"].create(
             {
                 "name": "pm time and material",
-                "detailed_type": "service",
+                "type": "service",
                 "service_tracking": "task_in_project",
                 "price": 120,
                 "standard_price": 100,
@@ -499,8 +499,6 @@ def test_task_forecast_lines_consolidated_forecast(self):
             )
             self.assertEqual(len(employee_forecast), 1)
             project = self.env["project.project"].create({"name": "TestProject"})
-            # set project in stage "in progress" to get confirmed forecast
-            project.stage_id = self.env.ref("project.project_project_stage_1")
             task = self.env["project.task"].create(
                 {
                     "name": "Task1",
@@ -512,8 +510,9 @@ def test_task_forecast_lines_consolidated_forecast(self):
                 }
             )
             task.remaining_hours = 6
-            task.user_ids = self.user_consultant
+            task.user_id = self.user_consultant
             forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
+            forecast.type = "confirmed"
             self.assertEqual(len(forecast), 1)
             # using assertEqual on purpose here
             self.assertEqual(forecast.forecast_hours, -6.0)
@@ -533,8 +532,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self):
             )
             self.assertEqual(len(employee_forecast), 1)
             project = self.env["project.project"].create({"name": "TestProject"})
-            # set project in stage "in progress" to get confirmed forecast
-            project.stage_id = self.env.ref("project.project_project_stage_1")
             task = self.env["project.task"].create(
                 {
                     "name": "Task1",
@@ -546,8 +543,9 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self):
                 }
             )
             task.remaining_hours = 10
-            task.user_ids = self.user_consultant
+            task.user_id = self.user_consultant
             forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
+            forecast.type = "confirmed"
             self.assertEqual(len(forecast), 1)
             # using assertEqual on purpose here
             self.assertEqual(forecast.forecast_hours, -10.0)
@@ -569,8 +567,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
             )
             self.assertEqual(len(employee_forecast), 1)
             project = self.env["project.project"].create({"name": "TestProject"})
-            # set project in stage "in progress" to get confirmed forecast
-            project.stage_id = self.env.ref("project.project_project_stage_1")
             task1 = self.env["project.task"].create(
                 {
                     "name": "Task1",
@@ -582,8 +578,9 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
                 }
             )
             task1.remaining_hours = 10
-            task1.user_ids = self.user_consultant
+            task1.user_id = self.user_consultant
             forecast1 = self.env["forecast.line"].search([("task_id", "=", task1.id)])
+            forecast1.type = "confirmed"
             self.assertEqual(len(forecast1), 1)
             task2 = self.env["project.task"].create(
                 {
@@ -596,8 +593,9 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
                 }
             )
             task2.remaining_hours = 4
-            task2.user_ids = self.user_consultant
+            task2.user_id = self.user_consultant
             forecast2 = self.env["forecast.line"].search([("task_id", "=", task2.id)])
+            forecast2.type = "confirmed"
             # using assertEqual on purpose here
             self.assertEqual(
                 forecast1.employee_resource_forecast_line_id,
@@ -643,8 +641,6 @@ def test_task_forecast_lines_employee_different_roles(self):
         )
         consultant_role.rate = 75
         project = self.env["project.project"].create({"name": "TestProjectDiffRoles"})
-        # set project in stage "in progress" to get confirmed forecast
-        project.stage_id = self.env.ref("project.project_project_stage_1")
         task = self.env["project.task"].create(
             {
                 "name": "TaskDiffRoles",
@@ -655,7 +651,7 @@ def test_task_forecast_lines_employee_different_roles(self):
                 "planned_hours": 8,
             }
         )
-        task.user_ids = self.user_consultant
+        task.user_id = self.user_consultant
         task_forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
         self.assertEqual(len(task_forecast), 1)
         # using assertEqual on purpose here
@@ -669,6 +665,7 @@ def test_task_forecast_lines_employee_different_roles(self):
             lambda l: l.res_model == "hr.employee.forecast.role"
             and l.forecast_role_id == self.role_consultant
         )[0]
+        employee_forecast.type = "confirmed"
         self.assertEqual(forecast_consultant.forecast_hours, 6.0)
         self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25)
         forecast_pm = employee_forecast.filtered(
@@ -714,8 +711,6 @@ def test_task_forecast_lines_employee_main_role(self):
         )
         consultant_role.rate = 75
         project = self.env["project.project"].create({"name": "TestProjectDiffRoles"})
-        # set project in stage "in progress" to get confirmed forecast
-        project.stage_id = self.env.ref("project.project_project_stage_1")
         task = self.env["project.task"].create(
             {
                 "name": "TaskDiffRoles",
@@ -726,7 +721,7 @@ def test_task_forecast_lines_employee_main_role(self):
                 "planned_hours": 8,
             }
         )
-        task.user_ids = self.user_consultant
+        task.user_id = self.user_consultant
         task_forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
         self.assertEqual(len(task_forecast), 1)
         # using assertEqual on purpose here
@@ -735,6 +730,7 @@ def test_task_forecast_lines_employee_main_role(self):
         employee_forecast = self.env["forecast.line"].search(
             [("employee_id", "=", self.employee_consultant.id)]
         )
+        employee_forecast.type = "confirmed"
         # we can take first line to check as forecast values are equal
         forecast_consultant = employee_forecast.filtered(
             lambda l: l.res_model == "hr.employee.forecast.role"
diff --git a/setup/project_forecast_line/odoo/addons/project_forecast_line b/setup/project_forecast_line/odoo/addons/project_forecast_line
new file mode 120000
index 0000000000..c46f8df746
--- /dev/null
+++ b/setup/project_forecast_line/odoo/addons/project_forecast_line
@@ -0,0 +1 @@
+../../../../project_forecast_line
\ No newline at end of file
diff --git a/setup/project_forecast_line/setup.py b/setup/project_forecast_line/setup.py
new file mode 100644
index 0000000000..28c57bb640
--- /dev/null
+++ b/setup/project_forecast_line/setup.py
@@ -0,0 +1,6 @@
+import setuptools
+
+setuptools.setup(
+    setup_requires=['setuptools-odoo'],
+    odoo_addon=True,
+)

From 8cafc370dfa19c7bcdbe204eee49d45b5b3734bd Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Wed, 7 Sep 2022 10:02:16 +0300
Subject: [PATCH 15/33] [15.0][FIX] project_forecast_line: add missing access
 rights

---
 project_forecast_line/__manifest__.py              | 1 +
 project_forecast_line/models/forecast_line.py      | 9 +++++++--
 project_forecast_line/models/hr_employee.py        | 6 ++++--
 project_forecast_line/models/product_template.py   | 2 +-
 project_forecast_line/models/project_task.py       | 2 +-
 project_forecast_line/security/ir.model.access.csv | 4 ++--
 6 files changed, 16 insertions(+), 8 deletions(-)

diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
index 91b4ceb4b7..6ee8255ff2 100644
--- a/project_forecast_line/__manifest__.py
+++ b/project_forecast_line/__manifest__.py
@@ -22,5 +22,6 @@
         "data/ir_cron.xml",
     ],
     "installable": True,
+    "development_status": "Alpha",
     "application": True,
 }
diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index 11cfc325a3..ea2c08da13 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -27,11 +27,16 @@ class ForecastLine(models.Model):
     )
     date_to = fields.Date(required=True)
     forecast_role_id = fields.Many2one(
-        "forecast.role", string="Forecast role", required=True, index=True
+        "forecast.role",
+        string="Forecast role",
+        required=True,
+        index=True,
+        ondelete="restrict",
     )
     employee_id = fields.Many2one("hr.employee", string="Employee")
     employee_forecast_role_id = fields.Many2one(
-        "hr.employee.forecast.role", string="Employee Forecast Role"
+        "hr.employee.forecast.role",
+        string="Employee Forecast Role",
     )
     project_id = fields.Many2one("project.project", index=True, string="Project")
     task_id = fields.Many2one("project.task", index=True, string="Task")
diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py
index 60715aebd3..9afb519899 100644
--- a/project_forecast_line/models/hr_employee.py
+++ b/project_forecast_line/models/hr_employee.py
@@ -9,14 +9,16 @@
 class HrJob(models.Model):
     _inherit = "hr.job"
 
-    role_id = fields.Many2one("forecast.role")
+    role_id = fields.Many2one("forecast.role", ondelete="restrict")
 
 
 class HrEmployee(models.Model):
     _inherit = "hr.employee"
 
     role_ids = fields.One2many("hr.employee.forecast.role", "employee_id")
-    main_role_id = fields.Many2one("forecast.role", compute="_compute_main_role_id")
+    main_role_id = fields.Many2one(
+        "forecast.role", compute="_compute_main_role_id", ondelete="restrict"
+    )
 
     def _compute_main_role_id(self):
         # can"t store as it depends on current date
diff --git a/project_forecast_line/models/product_template.py b/project_forecast_line/models/product_template.py
index 6fd7faa8d8..fb9874266a 100644
--- a/project_forecast_line/models/product_template.py
+++ b/project_forecast_line/models/product_template.py
@@ -6,4 +6,4 @@
 class ProductTemplate(models.Model):
     _inherit = "product.template"
 
-    forecast_role_id = fields.Many2one("forecast.role")
+    forecast_role_id = fields.Many2one("forecast.role", ondelete="restrict")
diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py
index b868b4ef31..da81740962 100644
--- a/project_forecast_line/models/project_task.py
+++ b/project_forecast_line/models/project_task.py
@@ -10,7 +10,7 @@
 class ProjectTask(models.Model):
     _inherit = "project.task"
 
-    forecast_role_id = fields.Many2one("forecast.role")
+    forecast_role_id = fields.Many2one("forecast.role", ondelete="restrict")
     forecast_date_planned_start = fields.Date("Planned start date")
     forecast_date_planned_end = fields.Date("Planned end date")
 
diff --git a/project_forecast_line/security/ir.model.access.csv b/project_forecast_line/security/ir.model.access.csv
index 9001101172..d6690ba17f 100644
--- a/project_forecast_line/security/ir.model.access.csv
+++ b/project_forecast_line/security/ir.model.access.csv
@@ -1,6 +1,6 @@
 id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
 project_forecast_line.access_forecast_line,access_forecast_line,project_forecast_line.model_forecast_line,base.group_user,1,0,0,0
 project_forecast_line.access_forecast_role,access_forecast_role,project_forecast_line.model_forecast_role,base.group_user,1,0,0,0
-project_forecast_line.access_forecast_role_hr_manager,access_forecast_role_hr_manager,project_forecast_line.model_forecast_role,hr.group_hr_user,1,1,1,0
+project_forecast_line.access_forecast_role_hr_user,access_forecast_role_hr_manager,project_forecast_line.model_forecast_role,hr.group_hr_user,1,1,1,1
 project_forecast_line.access_hr_employee_forecast_role,access_hr_employee_forecast_role,project_forecast_line.model_hr_employee_forecast_role,base.group_user,1,0,0,0
-project_forecast_line.access_hr_manager_forecast_role,access_hr_manager_forecast_role,project_forecast_line.model_hr_employee_forecast_role,hr.group_hr_user,1,1,1,0
+project_forecast_line.access_hr_employee_forecast_role_hr_user,access_hr_employee_forecast_role_hr_user,project_forecast_line.model_hr_employee_forecast_role,hr.group_hr_user,1,1,1,1

From 9a8eab7dafff6b3e341610273fbd351b098df790 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Wed, 17 Aug 2022 14:47:32 +0200
Subject: [PATCH 16/33] project_forecast_line: improve documentation

---
 project_forecast_line/README.rst              | 28 ++++---------------
 .../static/description/index.html             | 28 ++++---------------
 2 files changed, 10 insertions(+), 46 deletions(-)

diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst
index 72d80fd34f..87c7d75b27 100644
--- a/project_forecast_line/README.rst
+++ b/project_forecast_line/README.rst
@@ -45,13 +45,6 @@ whether the consumption is confirmed or not. For instance, holidays requests
 and sales quotation lines create lines of type "forecast", whereas tasks for
 project which are in a running state create lines with type "confirmed".
 
-To get the best experience using the Forecast application you may want to install:
-
-* project_forecast_line_holidays_public module which takes public holidays into
-  account during forecast lines creation
-
-* project_forecast_line_bokeh_chart module which improves the reports of
-  project_forecast_line module by using the bokeh widget available in OCA/web
 
 **Table of contents**
 
@@ -64,7 +57,7 @@ Usage
 Forecast lines have the following data:
 
 * Forecast hours: it is positive for resources (employees) and negative for
-  things which consume time (project tasks, for instance)
+  things which consume time
 
 * From and To date which are the beginning and ending of the period of the
   capacity
@@ -79,9 +72,6 @@ Forecast lines have the following data:
     positive if the employee still has some free time, and negative if he is
     overloaded with work.
 
-  * this consolidated forecast is currently converted to days to ease
-    readability of the forecast report
-
 
 Objects creating forecast lines:
 
@@ -97,9 +87,9 @@ Objects creating forecast lines:
 * confirmed sale orders don't create forecast lines. This is handled by the
   tasks created at the confirmation of the sale order
 
-* project tasks create forecast lines if they have a linked role and planned start/end
-  dates. The type of the line will depend on the related project's stage. The
-  `forecast_hours` field is based on the remaining time of the task, which is spread
+* project tasks create forecast lines if they have a linked role and start/end
+  date. The type of the line will depend on the related project's stage. The
+  forecast quantity is based on the remaining time of the task, which is spread
   on the work days of the planned start and end date of the task. If the
   current date is in the middle of the planned duration of the task, it is used
   as the start date. If the planned end date is in the past the task does not
@@ -114,15 +104,7 @@ Objects creating forecast lines:
   work calendar of the employee: the employee will not have a positive line
   associated to his leave days.
 
-The creation of forecast lines is done either in real time when some actions
-are performed by the user (requesting leaves, updating the remaining time on a
-project task, timesheeting) and also via a cron that runs on a daily basis. The
-cron is required to cleanup lines related to dates in the past and to recompute
-the lines related to project tasks by computing the ratio of remaing time on
-the tasks on the remaining days, for tasks which are in progress. So, to start
-using consolidated forecast report you first need to set everything mentioned
-in Usage section. Then, probably run Forecast recomputation cron manually from
-Scheduled Actions or wait till cron creates records.
+
 
 Bug Tracker
 ===========
diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html
index 4eefcd70df..29058b0689 100644
--- a/project_forecast_line/static/description/index.html
+++ b/project_forecast_line/static/description/index.html
@@ -3,7 +3,7 @@
 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
-<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
+<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
 <title>Project Forecast Lines</title>
 <style type="text/css">
 
@@ -384,13 +384,6 @@ <h1 class="title">Project Forecast Lines</h1>
 whether the consumption is confirmed or not. For instance, holidays requests
 and sales quotation lines create lines of type “forecast”, whereas tasks for
 project which are in a running state create lines with type “confirmed”.</p>
-<p>To get the best experience using the Forecast application you may want to install:</p>
-<ul class="simple">
-<li>project_forecast_line_holidays_public module which takes public holidays into
-account during forecast lines creation</li>
-<li>project_forecast_line_bokeh_chart module which improves the reports of
-project_forecast_line module by using the bokeh widget available in OCA/web</li>
-</ul>
 <p><strong>Table of contents</strong></p>
 <div class="contents local topic" id="contents">
 <ul class="simple">
@@ -409,7 +402,7 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 <p>Forecast lines have the following data:</p>
 <ul class="simple">
 <li>Forecast hours: it is positive for resources (employees) and negative for
-things which consume time (project tasks, for instance)</li>
+things which consume time</li>
 <li>From and To date which are the beginning and ending of the period of the
 capacity</li>
 <li>Consolidated forecast: this is a computed field, which is computed as follows:<ul>
@@ -419,8 +412,6 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 substract all the costs for that employee on the same period. So it will be
 positive if the employee still has some free time, and negative if he is
 overloaded with work.</li>
-<li>this consolidated forecast is currently converted to days to ease
-readability of the forecast report</li>
 </ul>
 </li>
 </ul>
@@ -435,9 +426,9 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 role and start and end dates. The forecast hours are negative</li>
 <li>confirmed sale orders don’t create forecast lines. This is handled by the
 tasks created at the confirmation of the sale order</li>
-<li>project tasks create forecast lines if they have a linked role and planned start/end
-dates. The type of the line will depend on the related project’s stage. The
-<cite>forecast_hours</cite> field is based on the remaining time of the task, which is spread
+<li>project tasks create forecast lines if they have a linked role and start/end
+date. The type of the line will depend on the related project’s stage. The
+forecast quantity is based on the remaining time of the task, which is spread
 on the work days of the planned start and end date of the task. If the
 current date is in the middle of the planned duration of the task, it is used
 as the start date. If the planned end date is in the past the task does not
@@ -450,15 +441,6 @@ <h1><a class="toc-backref" href="#id1">Usage</a></h1>
 work calendar of the employee: the employee will not have a positive line
 associated to his leave days.</li>
 </ul>
-<p>The creation of forecast lines is done either in real time when some actions
-are performed by the user (requesting leaves, updating the remaining time on a
-project task, timesheeting) and also via a cron that runs on a daily basis. The
-cron is required to cleanup lines related to dates in the past and to recompute
-the lines related to project tasks by computing the ratio of remaing time on
-the tasks on the remaining days, for tasks which are in progress. So, to start
-using consolidated forecast report you first need to set everything mentioned
-in Usage section. Then, probably run Forecast recomputation cron manually from
-Scheduled Actions or wait till cron creates records.</p>
 </div>
 <div class="section" id="bug-tracker">
 <h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>

From 1926c7b21fe92a3826f51591cbc339af0258f80a Mon Sep 17 00:00:00 2001
From: ntsirintanis <ntsirintanis@therp.nl>
Date: Mon, 12 Sep 2022 16:41:09 +0200
Subject: [PATCH 17/33] [14.0][IMP] project_forecast_line: replace
 project.project.stage with project.status

---
 project_forecast_line/__manifest__.py         |  4 ++-
 project_forecast_line/data/project_status.xml | 12 +++++++
 project_forecast_line/models/__init__.py      |  2 ++
 .../models/project_project.py                 | 18 ++++++++++
 .../models/project_status.py                  | 21 ++++++++++++
 project_forecast_line/models/project_task.py  |  9 +++--
 .../tests/test_forecast_line.py               | 25 +++++++++++---
 .../views/project_project_stage_views.xml     | 34 -------------------
 .../views/project_status_views.xml            | 13 +++++++
 9 files changed, 96 insertions(+), 42 deletions(-)
 create mode 100644 project_forecast_line/data/project_status.xml
 create mode 100644 project_forecast_line/models/project_project.py
 create mode 100644 project_forecast_line/models/project_status.py
 delete mode 100644 project_forecast_line/views/project_project_stage_views.xml
 create mode 100644 project_forecast_line/views/project_status_views.xml

diff --git a/project_forecast_line/__manifest__.py b/project_forecast_line/__manifest__.py
index 6ee8255ff2..b9d137e50b 100644
--- a/project_forecast_line/__manifest__.py
+++ b/project_forecast_line/__manifest__.py
@@ -8,7 +8,7 @@
     "license": "AGPL-3",
     "category": "Project",
     "website": "https://github.com/OCA/project",
-    "depends": ["sale_timesheet", "sale_project", "hr_holidays"],
+    "depends": ["sale_timesheet", "sale_project", "hr_holidays", "project_status"],
     "data": [
         "security/forecast_line_security.xml",
         "security/ir.model.access.csv",
@@ -18,8 +18,10 @@
         "views/forecast_role_views.xml",
         "views/product_views.xml",
         "views/project_task_views.xml",
+        "views/project_status_views.xml",
         "views/res_config_settings_views.xml",
         "data/ir_cron.xml",
+        "data/project_status.xml",
     ],
     "installable": True,
     "development_status": "Alpha",
diff --git a/project_forecast_line/data/project_status.xml b/project_forecast_line/data/project_status.xml
new file mode 100644
index 0000000000..5aa06e983c
--- /dev/null
+++ b/project_forecast_line/data/project_status.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <!-- Project Status -->
+    <record id="project_status.project_status_pending" model="project.status">
+        <field name="forecast_line_type">forecast</field>
+    </record>
+
+    <record id="project_status.project_status_in_progress" model="project.status">
+        <field name="forecast_line_type">confirmed</field>
+    </record>
+
+</odoo>
diff --git a/project_forecast_line/models/__init__.py b/project_forecast_line/models/__init__.py
index 972739fd50..da3eba8fb0 100644
--- a/project_forecast_line/models/__init__.py
+++ b/project_forecast_line/models/__init__.py
@@ -10,3 +10,5 @@
 from . import account_analytic_line
 from . import res_config_settings
 from . import resource_calendar_leaves
+from . import project_project
+from . import project_status
diff --git a/project_forecast_line/models/project_project.py b/project_forecast_line/models/project_project.py
new file mode 100644
index 0000000000..aeb43d4613
--- /dev/null
+++ b/project_forecast_line/models/project_project.py
@@ -0,0 +1,18 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import models
+
+
+class ProjectProject(models.Model):
+    _inherit = "project.project"
+
+    def _update_forecast_lines_trigger_fields(self):
+        return ["project_status"]
+
+    def write(self, values):
+        res = super().write(values)
+        written_fields = list(values.keys())
+        trigger_fields = self._update_forecast_lines_trigger_fields()
+        if any(field in written_fields for field in trigger_fields):
+            self.task_ids._update_forecast_lines()
+        return res
diff --git a/project_forecast_line/models/project_status.py b/project_forecast_line/models/project_status.py
new file mode 100644
index 0000000000..5faca41f19
--- /dev/null
+++ b/project_forecast_line/models/project_status.py
@@ -0,0 +1,21 @@
+# Copyright 2022 Camptocamp SA
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ProjectStatus(models.Model):
+    _inherit = "project.status"
+
+    forecast_line_type = fields.Selection(
+        [("forecast", "Forecast"), ("confirmed", "Confirmed")],
+        help="type of forecast lines created by the tasks of projects in that status",
+    )
+
+    def write(self, values):
+        res = super().write(values)
+        if "forecast_line_type" in values:
+            projects = self.env["project.project"].search(
+                [("project_status", "in", self.ids)]
+            )
+            projects.mapped("task_ids")._update_forecast_lines()
+        return res
diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py
index da81740962..d6ff3535f6 100644
--- a/project_forecast_line/models/project_task.py
+++ b/project_forecast_line/models/project_task.py
@@ -72,14 +72,19 @@ def _update_forecast_lines(self):
             [("res_id", "in", self.ids), ("res_model", "=", self._name)]
         ).unlink()
         for task in self:
-            forecast_type = "forecast"
             if not task.forecast_role_id:
                 _logger.info("skip task %s: no forecast role", task)
                 continue
-            if task.sale_line_id:
+            elif task.project_id.project_status:
+                forecast_type = task.project_id.project_status.forecast_line_type
+                if not forecast_type:
+                    _logger.info("skip task %s: no forecast for project state", task)
+                    continue  # closed / cancelled stage
+            elif task.sale_line_id:
                 sale_state = task.sale_line_id.state
                 if sale_state == "cancel":
                     _logger.info("skip task %s: cancelled sale", task)
+                    continue
                 elif sale_state == "sale":
                     forecast_type = "confirmed"
                 else:
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index ad63c1edd2..499efd8f54 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -499,6 +499,10 @@ def test_task_forecast_lines_consolidated_forecast(self):
             )
             self.assertEqual(len(employee_forecast), 1)
             project = self.env["project.project"].create({"name": "TestProject"})
+            # set project in stage "in progress" to get confirmed forecast
+            project.project_status = self.env.ref(
+                "project_status.project_status_in_progress"
+            )
             task = self.env["project.task"].create(
                 {
                     "name": "Task1",
@@ -512,7 +516,6 @@ def test_task_forecast_lines_consolidated_forecast(self):
             task.remaining_hours = 6
             task.user_id = self.user_consultant
             forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
-            forecast.type = "confirmed"
             self.assertEqual(len(forecast), 1)
             # using assertEqual on purpose here
             self.assertEqual(forecast.forecast_hours, -6.0)
@@ -532,6 +535,10 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self):
             )
             self.assertEqual(len(employee_forecast), 1)
             project = self.env["project.project"].create({"name": "TestProject"})
+            # set project in stage "in progress" to get confirmed forecast
+            project.project_status = self.env.ref(
+                "project_status.project_status_in_progress"
+            )
             task = self.env["project.task"].create(
                 {
                     "name": "Task1",
@@ -545,7 +552,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self):
             task.remaining_hours = 10
             task.user_id = self.user_consultant
             forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
-            forecast.type = "confirmed"
             self.assertEqual(len(forecast), 1)
             # using assertEqual on purpose here
             self.assertEqual(forecast.forecast_hours, -10.0)
@@ -567,6 +573,10 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
             )
             self.assertEqual(len(employee_forecast), 1)
             project = self.env["project.project"].create({"name": "TestProject"})
+            # set project in stage "in progress" to get confirmed forecast
+            project.project_status = self.env.ref(
+                "project_status.project_status_in_progress"
+            )
             task1 = self.env["project.task"].create(
                 {
                     "name": "Task1",
@@ -580,7 +590,6 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
             task1.remaining_hours = 10
             task1.user_id = self.user_consultant
             forecast1 = self.env["forecast.line"].search([("task_id", "=", task1.id)])
-            forecast1.type = "confirmed"
             self.assertEqual(len(forecast1), 1)
             task2 = self.env["project.task"].create(
                 {
@@ -641,6 +650,10 @@ def test_task_forecast_lines_employee_different_roles(self):
         )
         consultant_role.rate = 75
         project = self.env["project.project"].create({"name": "TestProjectDiffRoles"})
+        # set project in stage "in progress" to get confirmed forecast
+        project.project_status = self.env.ref(
+            "project_status.project_status_in_progress"
+        )
         task = self.env["project.task"].create(
             {
                 "name": "TaskDiffRoles",
@@ -665,7 +678,6 @@ def test_task_forecast_lines_employee_different_roles(self):
             lambda l: l.res_model == "hr.employee.forecast.role"
             and l.forecast_role_id == self.role_consultant
         )[0]
-        employee_forecast.type = "confirmed"
         self.assertEqual(forecast_consultant.forecast_hours, 6.0)
         self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25)
         forecast_pm = employee_forecast.filtered(
@@ -711,6 +723,10 @@ def test_task_forecast_lines_employee_main_role(self):
         )
         consultant_role.rate = 75
         project = self.env["project.project"].create({"name": "TestProjectDiffRoles"})
+        # set project in stage "in progress" to get confirmed forecast
+        project.project_status = self.env.ref(
+            "project_status.project_status_in_progress"
+        )
         task = self.env["project.task"].create(
             {
                 "name": "TaskDiffRoles",
@@ -730,7 +746,6 @@ def test_task_forecast_lines_employee_main_role(self):
         employee_forecast = self.env["forecast.line"].search(
             [("employee_id", "=", self.employee_consultant.id)]
         )
-        employee_forecast.type = "confirmed"
         # we can take first line to check as forecast values are equal
         forecast_consultant = employee_forecast.filtered(
             lambda l: l.res_model == "hr.employee.forecast.role"
diff --git a/project_forecast_line/views/project_project_stage_views.xml b/project_forecast_line/views/project_project_stage_views.xml
deleted file mode 100644
index c93ccc0b17..0000000000
--- a/project_forecast_line/views/project_project_stage_views.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-<?xml version="1.0" encoding="utf-8" ?>
-<odoo>
-    <record id="project_project_stage_view_tree" model="ir.ui.view">
-        <field name="model">project.project.stage</field>
-        <field name="inherit_id" ref="project.project_project_stage_view_tree" />
-        <field name="arch" type="xml">
-            <field name="name" position="after">
-                <field name="forecast_line_type" />
-            </field>
-        </field>
-    </record>
-    <record id="project_project_stage_view_form_quick_create" model="ir.ui.view">
-        <field name="model">project.project.stage</field>
-        <field
-            name="inherit_id"
-            ref="project.project_project_stage_view_form_quick_create"
-        />
-        <field name="arch" type="xml">
-            <field name="name" position="after">
-                <field name="forecast_line_type" />
-            </field>
-        </field>
-    </record>
-    <record id="project_project_stage_view_form" model="ir.ui.view">
-        <field name="inherit_id" ref="project.project_project_stage_view_form" />
-        <field name="model">project.project.stage</field>
-        <field name="arch" type="xml">
-            <field name="active" position="after">
-                <field name="forecast_line_type" />
-            </field>
-        </field>
-    </record>
-
-</odoo>
diff --git a/project_forecast_line/views/project_status_views.xml b/project_forecast_line/views/project_status_views.xml
new file mode 100644
index 0000000000..84c0d0bbd5
--- /dev/null
+++ b/project_forecast_line/views/project_status_views.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<odoo>
+    <record id="project_status_view_list" model="ir.ui.view">
+        <field name="model">project.status</field>
+        <field name="inherit_id" ref="project_status.project_status_view_list" />
+        <field name="arch" type="xml">
+            <field name="name" position="after">
+                <field name="forecast_line_type" />
+            </field>
+        </field>
+    </record>
+
+</odoo>

From f30cf989bac4554934928c0353f6bfbcfd3bc7f0 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Tue, 13 Sep 2022 14:03:54 +0200
Subject: [PATCH 18/33] [FIX] project_forecast_line: error in consolidated
 capacity

During some operations, forecast lines are deleted and recreated. This
can lead to some hr.employee.forecast.role lines being deleted which
leaves the forecast lines on the same period without a related document
to store the consolidated capacity. Normally that line is recreated
shortly afterwards but the creation does not recompute the link between
the parent-less lines and the new one. This patches forces the
recomputation when new lines are created.
---
 project_forecast_line/models/forecast_line.py | 23 +++++++++++++
 project_forecast_line/models/hr_leave.py      |  1 +
 .../tests/test_forecast_line.py               | 32 +++++++++++++++++++
 3 files changed, 56 insertions(+)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index ea2c08da13..bd72233842 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -348,3 +348,26 @@ def unlink(self):
         # we routinely unlink forecast lines, let"s not fill the logs with this
         with mute_logger("odoo.models.unlink"):
             return super().unlink()
+
+    @api.model_create_multi
+    @api.returns("self", lambda value: value.id)
+    def create(self, vals_list):
+        records = super().create(vals_list)
+        employee_role_lines = records.filtered(
+            lambda r: r.res_model == "hr.employee.forecast.role"
+        )
+        if employee_role_lines:
+            # check for existing records which could have the new lines as
+            # employee_resource_forecast_line_id
+            other_lines = self.search(
+                [
+                    ("employee_resource_forecast_line_id", "=", False),
+                    (
+                        "employee_id",
+                        "in",
+                        employee_role_lines.mapped("employee_id").ids,
+                    ),
+                ]
+            )
+            other_lines._compute_employee_forecast_line_id()
+        return records
diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py
index 88f59c303c..e74a7699af 100644
--- a/project_forecast_line/models/hr_leave.py
+++ b/project_forecast_line/models/hr_leave.py
@@ -53,6 +53,7 @@ def _update_forecast_lines(self):
                 unit_cost=leave.employee_id.timesheet_cost,
                 forecast_role_id=leave.employee_id.main_role_id.id,
                 hr_leave_id=leave.id,
+                employee_id=leave.employee_id.id,
                 res_model=self._name,
                 res_id=leave.id,
             )
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index 499efd8f54..c878d133fd 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -525,6 +525,38 @@ def test_task_forecast_lines_consolidated_forecast(self):
                 0.25,
             )
 
+    @freeze_time("2022-01-01 12:00:00")
+    def test_forecast_with_holidays(self):
+        self.test_task_forecast_lines_consolidated_forecast()
+        with Form(self.env["hr.leave"]) as form:
+            form.employee_id = self.employee_consultant
+            form.holiday_status_id = self.env.ref("hr_holidays.holiday_status_unpaid")
+            form.request_date_from = "2022-02-14"
+            form.request_date_to = "2022-02-15"
+            form.request_hour_from = "8"
+            form.request_hour_to = "18"
+        leave_request = form.save()
+        # validating the leave request will recompute the forecast lines for
+        # the employee capactities (actually delete the existing ones and
+        # create new ones -> we check that the project task lines are
+        # automatically related to the new newly created employee role lines.
+        leave_request.action_validate()
+        forecast_lines = self.env["forecast.line"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("res_model", "=", "hr.employee.forecast.role"),
+                ("date_from", ">=", "2022-02-14"),
+                ("date_to", "<=", "2022-02-15"),
+            ]
+        )
+        self.assertEqual(len(forecast_lines), 2)
+        # both new lines have now a capacity of 0 (employee is on holidays)
+        self.assertEqual(forecast_lines[0].forecast_hours, 0)
+        self.assertEqual(forecast_lines[1].forecast_hours, 0)
+        # first line has a negative consolidated forcast (because of the task)
+        self.assertEqual(forecast_lines[0].consolidated_forecast, -0.75)
+        self.assertEqual(forecast_lines[1].consolidated_forecast, -0)
+
     def test_task_forecast_lines_consolidated_forecast_overallocation(self):
         with freeze_time("2022-01-01"):
             employee_forecast = self.env["forecast.line"].search(

From a9b28d3f44627ebf7709ef1a46014a48dac067a9 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Tue, 11 Oct 2022 11:42:57 +0200
Subject: [PATCH 19/33] [FIX] project_forecast_line: handle lack of project
 status

In some cases you can have a project without a status set -> in this
case we don't want to generate forecast lines (and we don't want a crash
either)
---
 project_forecast_line/models/project_task.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/project_forecast_line/models/project_task.py b/project_forecast_line/models/project_task.py
index d6ff3535f6..6cc02cb507 100644
--- a/project_forecast_line/models/project_task.py
+++ b/project_forecast_line/models/project_task.py
@@ -80,6 +80,9 @@ def _update_forecast_lines(self):
                 if not forecast_type:
                     _logger.info("skip task %s: no forecast for project state", task)
                     continue  # closed / cancelled stage
+            elif not task.project_id.project_status:
+                _logger.info("skip task %s: no project status set", task)
+                continue  # not status on project
             elif task.sale_line_id:
                 sale_state = task.sale_line_id.state
                 if sale_state == "cancel":

From 2a45f7950b1f6aed1d4beec677d1b87533d0ed4a Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Thu, 15 Sep 2022 11:50:32 +0300
Subject: [PATCH 20/33] Revert "[15.0][IMP] project_forecast_line: setting to
 control consumption states"

This reverts commit 82f5f363fe43f83c82a4a374314bf489fe12bb99.
---
 project_forecast_line/models/forecast_line.py    | 10 +---------
 project_forecast_line/models/res_company.py      | 16 ----------------
 .../models/res_config_settings.py                |  4 +---
 .../views/res_config_settings_views.xml          | 11 -----------
 4 files changed, 2 insertions(+), 39 deletions(-)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index bd72233842..2d76430126 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -90,13 +90,8 @@ class ForecastLine(models.Model):
         "forecast.line", "employee_resource_forecast_line_id"
     )
 
-    def _get_consumption_states(self):
-        consumption_states = self.env.company.forecast_consumption_states
-        return tuple(consumption_states.split("_"))
-
     @api.depends("employee_id", "date_from", "type", "res_model")
     def _compute_employee_forecast_line_id(self):
-        consumption_states = self._get_consumption_states()
         employees = self.mapped("employee_id")
         main_roles = employees.mapped("main_role_id")
         date_froms = self.mapped("date_from")
@@ -121,10 +116,7 @@ def _compute_employee_forecast_line_id(self):
                 (line.employee_id.id, line.date_from, line.forecast_role_id.id)
             ] = line.id
         for rec in self:
-            if (
-                rec.type in consumption_states
-                and rec.res_model != "hr.employee.forecast.role"
-            ):
+            if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role":
                 resource_forecast_line = capacities.get(
                     (rec.employee_id.id, rec.date_from, rec.forecast_role_id.id), False
                 )
diff --git a/project_forecast_line/models/res_company.py b/project_forecast_line/models/res_company.py
index c3a41c0661..4d88fee87d 100644
--- a/project_forecast_line/models/res_company.py
+++ b/project_forecast_line/models/res_company.py
@@ -14,22 +14,6 @@ class ResCompany(models.Model):
     forecast_line_horizon = fields.Integer(
         help="Number of month for the forecast planning", default=12
     )
-    forecast_consumption_states = fields.Selection(
-        selection=[
-            ("confirmed", "Compute consolidated forecast for lines of type confirmed"),
-            (
-                "forecast_confirmed",
-                "Include lines of type forecast in consolidated forecast computation",
-            ),
-        ],
-        string="Consumption state rules",
-        help="For instance, holidays requests and sales quotation lines"
-        "create lines of type forecast and won't be taken into account"
-        "during consolidated forecast computation, whereas tasks for project"
-        "which are in a running state create lines with type confirmed"
-        "and will be used to compute consolidated forecast.",
-        default="confirmed",
-    )
 
     def write(self, values):
         res = super().write(values)
diff --git a/project_forecast_line/models/res_config_settings.py b/project_forecast_line/models/res_config_settings.py
index 4c7cc90534..4ac43649ec 100644
--- a/project_forecast_line/models/res_config_settings.py
+++ b/project_forecast_line/models/res_config_settings.py
@@ -12,9 +12,7 @@ class ResConfigSettings(models.TransientModel):
     forecast_line_horizon = fields.Integer(
         related="company_id.forecast_line_horizon", readonly=False
     )
-    forecast_consumption_states = fields.Selection(
-        related="company_id.forecast_consumption_states", readonly=False
-    )
+
     group_forecast_line_on_quotation = fields.Boolean(
         "Forecast Line on Quotations",
         implied_group="project_forecast_line.group_forecast_line_on_quotation",
diff --git a/project_forecast_line/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml
index 61b15942a2..e273587afb 100644
--- a/project_forecast_line/views/res_config_settings_views.xml
+++ b/project_forecast_line/views/res_config_settings_views.xml
@@ -50,17 +50,6 @@
                                             class="o_field_integer o_field_number o_field_widget o_input oe_inline col-lg-2"
                                         />
                                     </div>
-                                    <div class="mt8">
-                                        <label for="forecast_consumption_states" />
-                                        <div class="text-muted">
-                                            Select the states for which the consumption is confirmed or not
-                                        </div>
-                                        <field
-                                            name="forecast_consumption_states"
-                                            required="1"
-                                            class="o_light_label"
-                                        />
-                                    </div>
                                 </div>
                             </div>
                         </div>

From 736ad37d78f3a07226cf1731b2c97dfbc6a3a002 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Thu, 15 Sep 2022 14:11:41 +0300
Subject: [PATCH 21/33] [FIX] project_forecast_line:

The idea is to have two consolidated forecast fields, instead of setting.
---
 project_forecast_line/models/forecast_line.py |  86 ++++++---
 .../tests/test_forecast_line.py               | 168 +++++++++++-------
 .../views/forecast_line_views.xml             |   6 +-
 3 files changed, 172 insertions(+), 88 deletions(-)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index 2d76430126..5b38e7fb17 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -61,11 +61,18 @@ class ForecastLine(models.Model):
         "holidays, sales, or tasks. ",
     )
     consolidated_forecast = fields.Float(
+        help="Consolidated forecast for lines of all types consumed",
+        digits=(12, 5),
+        store=True,
+        compute="_compute_consolidated_forecast",
+    )
+    confirmed_consolidated_forecast = fields.Float(
+        string="Confirmed lines consolidated forecast",
+        help="Consolidated forecast for lines of type confirmed",
         digits=(12, 5),
         store=True,
         compute="_compute_consolidated_forecast",
     )
-
     currency_id = fields.Many2one(related="company_id.currency_id", store=True)
     company_id = fields.Many2one(
         "res.company", required=True, default=lambda s: s.env.company
@@ -112,11 +119,15 @@ def _compute_employee_forecast_line_id(self):
             lines = self.env["forecast.line"]
         capacities = {}
         for line in lines:
-            capacities[
-                (line.employee_id.id, line.date_from, line.forecast_role_id.id)
-            ] = line.id
+            employee_id = line.employee_id
+            date_from = line.date_from
+            forecast_role_id = line.forecast_role_id
+            capacities[(employee_id.id, date_from, forecast_role_id.id)] = line.id
         for rec in self:
-            if rec.type == "confirmed" and rec.res_model != "hr.employee.forecast.role":
+            if (
+                rec.type in ("forecast", "confirmed")
+                and rec.res_model != "hr.employee.forecast.role"
+            ):
                 resource_forecast_line = capacities.get(
                     (rec.employee_id.id, rec.date_from, rec.forecast_role_id.id), False
                 )
@@ -132,34 +143,55 @@ def _compute_employee_forecast_line_id(self):
             else:
                 rec.employee_resource_forecast_line_id = False
 
-    def _convert_forecast(self, data):
-        """
-        Converts consolidated forecast from hours to days
-        """
-        self.ensure_one()
+    def _get_grouped_line_values(self):
+        data = {}
+        grouped_line_result = self.env["forecast.line"].read_group(
+            [("employee_resource_forecast_line_id", "in", self.ids)],
+            fields=["forecast_hours"],
+            groupby=["employee_resource_forecast_line_id", "type"],
+            lazy=False,
+        )
+        for d in grouped_line_result:
+            line_id = d["employee_resource_forecast_line_id"][0]
+            if line_id not in data:
+                data[line_id] = {"confirmed": 0, "forecast": 0}
+            data[line_id][d["type"]] += d["forecast_hours"]
+        return data
+
+    def _convert_hours_to_days(self, hours):
         to_convert_uom = self.env.ref("uom.product_uom_day")
         project_time_mode_id = self.company_id.project_time_mode_id
-        if self.res_model != "hr.employee.forecast.role":
-            return -project_time_mode_id._compute_quantity(
-                self.forecast_hours, to_convert_uom, round=False
-            )
-        else:
-            forecast_hours = self.forecast_hours + data.get(self.id, 0)
-            return project_time_mode_id._compute_quantity(
-                forecast_hours, to_convert_uom, round=False
-            )
+        return project_time_mode_id._compute_quantity(
+            hours, to_convert_uom, round=False
+        )
 
     @api.depends("employee_resource_consumption_ids.forecast_hours", "forecast_hours")
     def _compute_consolidated_forecast(self):
-        data = {}
-        for d in self.env["forecast.line"].read_group(
-            [("employee_resource_forecast_line_id", "in", self.ids)],
-            fields=["forecast_hours"],
-            groupby=["employee_resource_forecast_line_id"],
-        ):
-            data[d["employee_resource_forecast_line_id"][0]] = d["forecast_hours"]
+        grouped_lines_values = self._get_grouped_line_values()
         for rec in self:
-            rec.consolidated_forecast = rec._convert_forecast(data)
+            if rec.res_model != "hr.employee.forecast.role":
+                rec.consolidated_forecast = (
+                    self._convert_hours_to_days(rec.forecast_hours) * -1
+                )
+                rec.confirmed_consolidated_forecast = (
+                    self._convert_hours_to_days(rec.forecast_hours) * -1
+                )
+            else:
+                resource_forecast = grouped_lines_values.get(rec.id, 0)
+                confirmed_forecast = (
+                    resource_forecast.get("confirmed", 0) if resource_forecast else 0
+                )
+                consumable_forecast = (
+                    confirmed_forecast + resource_forecast.get("forecast", 0)
+                    if resource_forecast
+                    else 0
+                )
+                rec.consolidated_forecast = self._convert_hours_to_days(
+                    rec.forecast_hours + consumable_forecast
+                )
+                rec.confirmed_consolidated_forecast = self._convert_hours_to_days(
+                    rec.forecast_hours + confirmed_forecast
+                )
 
     def prepare_forecast_lines(
         self,
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index c878d133fd..53cf43e116 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -4,10 +4,11 @@
 
 from freezegun import freeze_time
 
-from odoo.tests.common import Form, SavepointCase
+from odoo.tests.common import Form, TransactionCase, tagged
 
 
-class BaseForecastLineTest(SavepointCase):
+@tagged("-at_install", "post_install")
+class BaseForecastLineTest(TransactionCase):
     @classmethod
     @freeze_time("2022-01-01")
     def setUpClass(cls):
@@ -489,41 +490,91 @@ def setUpClass(cls):
             }
         )
 
+    def _get_employee_forecast(self):
+        employee_forecast = self.env["forecast.line"].search(
+            [("employee_id", "=", self.employee_consultant.id)]
+        )
+        # we can take first line to check as forecast values are equal
+        forecast_consultant = employee_forecast.filtered(
+            lambda l: l.res_model == "hr.employee.forecast.role"
+            and l.forecast_role_id == self.role_consultant
+        )[0]
+        forecast_pm = employee_forecast.filtered(
+            lambda l: l.res_model == "hr.employee.forecast.role"
+            and l.forecast_role_id == self.role_pm
+        )[0]
+        return forecast_consultant, forecast_pm
+
     def test_task_forecast_lines_consolidated_forecast(self):
-        with freeze_time("2022-01-01"):
-            employee_forecast = self.env["forecast.line"].search(
-                [
-                    ("employee_id", "=", self.employee_consultant.id),
-                    ("date_from", "=", "2022-02-14"),
-                ]
-            )
-            self.assertEqual(len(employee_forecast), 1)
-            project = self.env["project.project"].create({"name": "TestProject"})
-            # set project in stage "in progress" to get confirmed forecast
-            project.project_status = self.env.ref(
-                "project_status.project_status_in_progress"
-            )
-            task = self.env["project.task"].create(
-                {
-                    "name": "Task1",
-                    "project_id": project.id,
-                    "forecast_role_id": self.role_consultant.id,
-                    "forecast_date_planned_start": "2022-02-14",
-                    "forecast_date_planned_end": "2022-02-14",
-                    "planned_hours": 6,
-                }
-            )
-            task.remaining_hours = 6
-            task.user_id = self.user_consultant
-            forecast = self.env["forecast.line"].search([("task_id", "=", task.id)])
-            self.assertEqual(len(forecast), 1)
-            # using assertEqual on purpose here
-            self.assertEqual(forecast.forecast_hours, -6.0)
-            self.assertAlmostEqual(forecast.consolidated_forecast, 0.75)
-            self.assertEqual(
-                forecast.employee_resource_forecast_line_id.consolidated_forecast,
-                0.25,
-            )
+        self.env["hr.employee.forecast.role"].create(
+            {
+                "employee_id": self.employee_consultant.id,
+                "role_id": self.role_pm.id,
+                "date_start": "2022-01-01",
+                "rate": 25,
+                "sequence": 1,
+            }
+        )
+        consultant_role = self.env["hr.employee.forecast.role"].search(
+            [
+                ("employee_id", "=", self.employee_consultant.id),
+                ("role_id", "=", self.role_consultant.id),
+            ]
+        )
+        consultant_role.rate = 75
+        project_1 = self.env["project.project"].create({"name": "TestProject1"})
+        # set project in stage "to do" to get forecast
+        project_1.stage_id = self.env.ref("project.project_project_stage_0")
+        task_values = {
+            "project_id": project_1.id,
+            "forecast_role_id": self.role_consultant.id,
+            "forecast_date_planned_start": date.today(),
+            "forecast_date_planned_end": date.today(),
+            "planned_hours": 8,
+        }
+        task_values.update({"name": "Task1"})
+        task_1 = self.env["project.task"].create(task_values)
+        task_1.user_ids = self.user_consultant
+        task_values.update({"name": "Task2"})
+        task_2 = self.env["project.task"].create(task_values)
+        task_2.user_ids = self.user_consultant
+        project_2 = self.env["project.project"].create({"name": "TestProject2"})
+        # set project in stage "in progress" to get forecast
+        project_2.stage_id = self.env.ref("project.project_project_stage_1")
+        task_values.update({"project_id": project_2.id, "name": "Task3"})
+        task_3 = self.env["project.task"].create(task_values)
+        task_3.user_ids = self.user_consultant
+        task_values.update({"name": "Task4"})
+        task_4 = self.env["project.task"].create(task_values)
+        task_4.user_ids = self.user_consultant
+        forecast = self.env["forecast.line"].search(
+            [("task_id", "in", (task_1.id, task_2.id, task_3.id, task_4.id))]
+        )
+        self.assertEqual(len(forecast), 4)
+        # as we have multiple tasks to check we will divide by 4
+        self.assertAlmostEqual(sum(f.forecast_hours for f in forecast), -32.0)
+        self.assertAlmostEqual(sum(f.consolidated_forecast for f in forecast), 4.0)
+        self.assertAlmostEqual(
+            sum(f.confirmed_consolidated_forecast for f in forecast), 4.0
+        )
+        consol_employee_forecast = sum(
+            f.employee_resource_forecast_line_id.consolidated_forecast for f in forecast
+        )
+        confir_employee_forecast = sum(
+            f.employee_resource_forecast_line_id.confirmed_consolidated_forecast
+            for f in forecast
+        )
+        self.assertAlmostEqual(consol_employee_forecast, -13.0)
+        self.assertAlmostEqual(confir_employee_forecast, -5.0)
+        forecast_consultant, forecast_pm = self._get_employee_forecast()
+        self.assertEqual(forecast_consultant.forecast_hours, 6.0)
+        self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -3.25)
+        self.assertAlmostEqual(
+            forecast_consultant.confirmed_consolidated_forecast, -1.25
+        )
+        self.assertEqual(forecast_pm.forecast_hours, 2.0)
+        self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)
+        self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25)
 
     @freeze_time("2022-01-01 12:00:00")
     def test_forecast_with_holidays(self):
@@ -588,10 +639,15 @@ def test_task_forecast_lines_consolidated_forecast_overallocation(self):
             # using assertEqual on purpose here
             self.assertEqual(forecast.forecast_hours, -10.0)
             self.assertEqual(forecast.consolidated_forecast, 1.25)
+            self.assertEqual(forecast.confirmed_consolidated_forecast, 1.25)
             self.assertEqual(
                 forecast.employee_resource_forecast_line_id.consolidated_forecast,
                 -0.25,
             )
+            self.assertEqual(
+                forecast.employee_resource_forecast_line_id.confirmed_consolidated_forecast,
+                -0.25,
+            )
 
     def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks(
         self,
@@ -646,6 +702,10 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
                 forecast1.employee_resource_forecast_line_id.consolidated_forecast,
                 -0.75,
             )
+            self.assertAlmostEqual(
+                forecast1.employee_resource_forecast_line_id.confirmed_consolidated_forecast,
+                -0.75,
+            )
 
     def test_task_forecast_lines_employee_different_roles(self):
         """
@@ -702,22 +762,16 @@ def test_task_forecast_lines_employee_different_roles(self):
         # using assertEqual on purpose here
         self.assertEqual(task_forecast.forecast_hours, -8.0)
         self.assertEqual(task_forecast.consolidated_forecast, 1.0)
-        employee_forecast = self.env["forecast.line"].search(
-            [("employee_id", "=", self.employee_consultant.id)]
-        )
-        # we can take first line to check as forecast values are equal
-        forecast_consultant = employee_forecast.filtered(
-            lambda l: l.res_model == "hr.employee.forecast.role"
-            and l.forecast_role_id == self.role_consultant
-        )[0]
+        self.assertEqual(task_forecast.confirmed_consolidated_forecast, 1.0)
+        forecast_consultant, forecast_pm = self._get_employee_forecast()
         self.assertEqual(forecast_consultant.forecast_hours, 6.0)
         self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25)
-        forecast_pm = employee_forecast.filtered(
-            lambda l: l.res_model == "hr.employee.forecast.role"
-            and l.forecast_role_id == self.role_pm
-        )[0]
+        self.assertAlmostEqual(
+            forecast_consultant.confirmed_consolidated_forecast, -0.25
+        )
         self.assertEqual(forecast_pm.forecast_hours, 2.0)
         self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)
+        self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25)
 
     def test_task_forecast_lines_employee_main_role(self):
         """
@@ -775,19 +829,13 @@ def test_task_forecast_lines_employee_main_role(self):
         # using assertEqual on purpose here
         self.assertEqual(task_forecast.forecast_hours, -8.0)
         self.assertEqual(task_forecast.consolidated_forecast, 1.0)
-        employee_forecast = self.env["forecast.line"].search(
-            [("employee_id", "=", self.employee_consultant.id)]
-        )
-        # we can take first line to check as forecast values are equal
-        forecast_consultant = employee_forecast.filtered(
-            lambda l: l.res_model == "hr.employee.forecast.role"
-            and l.forecast_role_id == self.role_consultant
-        )[0]
+        self.assertEqual(task_forecast.confirmed_consolidated_forecast, 1.0)
+        forecast_consultant, forecast_pm = self._get_employee_forecast()
         self.assertEqual(forecast_consultant.forecast_hours, 6.0)
         self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -0.25)
-        forecast_pm = employee_forecast.filtered(
-            lambda l: l.res_model == "hr.employee.forecast.role"
-            and l.forecast_role_id == self.role_pm
-        )[0]
+        self.assertAlmostEqual(
+            forecast_consultant.confirmed_consolidated_forecast, -0.25
+        )
         self.assertEqual(forecast_pm.forecast_hours, 2.0)
         self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)
+        self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25)
diff --git a/project_forecast_line/views/forecast_line_views.xml b/project_forecast_line/views/forecast_line_views.xml
index 7b92e72d75..a9238b8bdd 100644
--- a/project_forecast_line/views/forecast_line_views.xml
+++ b/project_forecast_line/views/forecast_line_views.xml
@@ -64,7 +64,8 @@
                 <field name="date_to" />
                 <field name="forecast_hours" />
                 <field name="cost" />
-                <field name="consolidated_forecast" />
+                <field name="confirmed_consolidated_forecast" optional="show" />
+                <field name="consolidated_forecast" optional="show" />
                 <field name="forecast_role_id" />
                 <field name="currency_id" invisible="1" />
             </tree>
@@ -76,6 +77,7 @@
             <graph type="bar" stacked="0">
                 <field name="date_from" type="row" />
                 <field name="forecast_role_id" type="col" />
+                <field name="confirmed_consolidated_forecast" type="measure" />
                 <field name="consolidated_forecast" type="measure" />
                 <field name="cost" type="measure" />
                 <field name="forecast_hours" type="measure" />
@@ -91,6 +93,7 @@
                 <field name="date_from" type="row" />
                 <field name="employee_id" type="col" />
                 <field name="project_id" type="col" />
+                <field name="confirmed_consolidated_forecast" type="measure" />
                 <field name="consolidated_forecast" type="measure" />
             </graph>
         </field>
@@ -101,6 +104,7 @@
             <pivot>
                 <field name="date_from" type="col" />
                 <field name="forecast_role_id" type="row" />
+                <field name="confirmed_consolidated_forecast" type="measure" />
                 <field name="consolidated_forecast" type="measure" />
                 <field name="cost" type="measure" />
                 <field name="forecast_hours" type="measure" />

From 9984b0c89a0b4a4dab5bc1749ec18663a303a6e8 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Fri, 16 Sep 2022 16:04:34 +0300
Subject: [PATCH 22/33] [FIX] project_forecast_line: give sudo rights on
 hr.leave to create forecast

---
 project_forecast_line/models/hr_leave.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/project_forecast_line/models/hr_leave.py b/project_forecast_line/models/hr_leave.py
index e74a7699af..0eafedb057 100644
--- a/project_forecast_line/models/hr_leave.py
+++ b/project_forecast_line/models/hr_leave.py
@@ -29,7 +29,13 @@ def _update_forecast_lines(self):
             [("res_id", "in", self.ids), ("res_model", "=", self._name)]
         ).unlink()
         leaves = self.filtered_domain([("state", "!=", "refuse")])
-        for leave in leaves:
+        # we need to use sudo here, because forecast line creation
+        # requires access to fields declared on hr.employee
+        # we don't want to restrict them with `groups="hr.group_hr_user"`
+        # as this will require giving access to employee app,
+        # which isn't wanted on some projects
+        # for more details see here: .../addons/hr/models/hr_employee.py#L22
+        for leave in leaves.sudo():
             if not leave.employee_id.main_role_id:
                 _logger.warning(
                     "No forecast role for employee %s (%s)",

From 86694a569092ae0d76a1e0ccf4bde3b0e5a23f3e Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Tue, 11 Oct 2022 12:32:17 +0200
Subject: [PATCH 23/33] fix the computation of the field and the tests

---
 project_forecast_line/models/forecast_line.py | 17 ++---
 .../tests/test_forecast_line.py               | 70 +++++++++++++------
 2 files changed, 56 insertions(+), 31 deletions(-)

diff --git a/project_forecast_line/models/forecast_line.py b/project_forecast_line/models/forecast_line.py
index 5b38e7fb17..c192239b67 100644
--- a/project_forecast_line/models/forecast_line.py
+++ b/project_forecast_line/models/forecast_line.py
@@ -173,24 +173,25 @@ def _compute_consolidated_forecast(self):
                 rec.consolidated_forecast = (
                     self._convert_hours_to_days(rec.forecast_hours) * -1
                 )
-                rec.confirmed_consolidated_forecast = (
-                    self._convert_hours_to_days(rec.forecast_hours) * -1
-                )
+                if rec.type == "confirmed":
+                    rec.confirmed_consolidated_forecast = rec.consolidated_forecast
+                else:
+                    rec.confirmed_consolidated_forecast = 0.0
             else:
                 resource_forecast = grouped_lines_values.get(rec.id, 0)
-                confirmed_forecast = (
+                confirmed = (
                     resource_forecast.get("confirmed", 0) if resource_forecast else 0
                 )
-                consumable_forecast = (
-                    confirmed_forecast + resource_forecast.get("forecast", 0)
+                unconfirmed = (
+                    confirmed + resource_forecast.get("forecast", 0)
                     if resource_forecast
                     else 0
                 )
                 rec.consolidated_forecast = self._convert_hours_to_days(
-                    rec.forecast_hours + consumable_forecast
+                    rec.forecast_hours + unconfirmed
                 )
                 rec.confirmed_consolidated_forecast = self._convert_hours_to_days(
-                    rec.forecast_hours + confirmed_forecast
+                    rec.forecast_hours + confirmed
                 )
 
     def prepare_forecast_lines(
diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index 53cf43e116..8f05e4902a 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -505,7 +505,9 @@ def _get_employee_forecast(self):
         )[0]
         return forecast_consultant, forecast_pm
 
+    @freeze_time("2022-02-14 12:00:00")
     def test_task_forecast_lines_consolidated_forecast(self):
+        # set the consultant employee to 75% consultant and 25% PM
         self.env["hr.employee.forecast.role"].create(
             {
                 "employee_id": self.employee_consultant.id,
@@ -522,14 +524,19 @@ def test_task_forecast_lines_consolidated_forecast(self):
             ]
         )
         consultant_role.rate = 75
+
+        # Create 2 project and 2 tasks with role consultant with 8h planned on
+        # 1 day, assigned to the consultant
+        #
+        # Projet 1 is in TODO (not confirmed forecast)
         project_1 = self.env["project.project"].create({"name": "TestProject1"})
         # set project in stage "to do" to get forecast
         project_1.stage_id = self.env.ref("project.project_project_stage_0")
         task_values = {
             "project_id": project_1.id,
             "forecast_role_id": self.role_consultant.id,
-            "forecast_date_planned_start": date.today(),
-            "forecast_date_planned_end": date.today(),
+            "forecast_date_planned_start": "2022-02-14",
+            "forecast_date_planned_end": "2022-02-14",
             "planned_hours": 8,
         }
         task_values.update({"name": "Task1"})
@@ -538,8 +545,9 @@ def test_task_forecast_lines_consolidated_forecast(self):
         task_values.update({"name": "Task2"})
         task_2 = self.env["project.task"].create(task_values)
         task_2.user_ids = self.user_consultant
+
+        # Project 2 is in stage "in progress" to get forecast
         project_2 = self.env["project.project"].create({"name": "TestProject2"})
-        # set project in stage "in progress" to get forecast
         project_2.stage_id = self.env.ref("project.project_project_stage_1")
         task_values.update({"project_id": project_2.id, "name": "Task3"})
         task_3 = self.env["project.task"].create(task_values)
@@ -547,30 +555,40 @@ def test_task_forecast_lines_consolidated_forecast(self):
         task_values.update({"name": "Task4"})
         task_4 = self.env["project.task"].create(task_values)
         task_4.user_ids = self.user_consultant
+
+        # check forecast lines
         forecast = self.env["forecast.line"].search(
             [("task_id", "in", (task_1.id, task_2.id, task_3.id, task_4.id))]
         )
         self.assertEqual(len(forecast), 4)
-        # as we have multiple tasks to check we will divide by 4
-        self.assertAlmostEqual(sum(f.forecast_hours for f in forecast), -32.0)
-        self.assertAlmostEqual(sum(f.consolidated_forecast for f in forecast), 4.0)
-        self.assertAlmostEqual(
-            sum(f.confirmed_consolidated_forecast for f in forecast), 4.0
+        self.assertEqual(
+            forecast.mapped("forecast_hours"),
+            [
+                -8.0,
+            ]
+            * 4,
         )
-        consol_employee_forecast = sum(
-            f.employee_resource_forecast_line_id.consolidated_forecast for f in forecast
+        # consolidated forecast is in days of 8 hours
+        self.assertEqual(forecast.mapped("consolidated_forecast"), [1.0] * 4)
+        self.assertEqual(
+            forecast.filtered(lambda r: r.type == "forecast").mapped(
+                "confirmed_consolidated_forecast"
+            ),
+            [0.0] * 2,
         )
-        confir_employee_forecast = sum(
-            f.employee_resource_forecast_line_id.confirmed_consolidated_forecast
-            for f in forecast
+        self.assertEqual(
+            forecast.filtered(lambda r: r.type == "confirmed").mapped(
+                "confirmed_consolidated_forecast"
+            ),
+            [1.0] * 2,
         )
-        self.assertAlmostEqual(consol_employee_forecast, -13.0)
-        self.assertAlmostEqual(confir_employee_forecast, -5.0)
         forecast_consultant, forecast_pm = self._get_employee_forecast()
         self.assertEqual(forecast_consultant.forecast_hours, 6.0)
-        self.assertAlmostEqual(forecast_consultant.consolidated_forecast, -3.25)
         self.assertAlmostEqual(
-            forecast_consultant.confirmed_consolidated_forecast, -1.25
+            forecast_consultant.consolidated_forecast, 1.0 * 75 / 100 - 4
+        )
+        self.assertAlmostEqual(
+            forecast_consultant.confirmed_consolidated_forecast, 1.0 * 75 / 100 - 2
         )
         self.assertEqual(forecast_pm.forecast_hours, 2.0)
         self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)
@@ -600,13 +618,17 @@ def test_forecast_with_holidays(self):
                 ("date_to", "<=", "2022-02-15"),
             ]
         )
-        self.assertEqual(len(forecast_lines), 2)
+        # 1 line per role per day -> 4 lines
+        self.assertEqual(len(forecast_lines), 2 * 2)
+        forecast_lines_consultant = forecast_lines.filtered(
+            lambda r: r.forecast_role_id == self.role_consultant
+        )
         # both new lines have now a capacity of 0 (employee is on holidays)
-        self.assertEqual(forecast_lines[0].forecast_hours, 0)
-        self.assertEqual(forecast_lines[1].forecast_hours, 0)
-        # first line has a negative consolidated forcast (because of the task)
-        self.assertEqual(forecast_lines[0].consolidated_forecast, -0.75)
-        self.assertEqual(forecast_lines[1].consolidated_forecast, -0)
+        self.assertEqual(forecast_lines_consultant[0].forecast_hours, 0)
+        self.assertEqual(forecast_lines_consultant[1].forecast_hours, 0)
+        # first line has a negative consolidated forecast (because of the task)
+        self.assertEqual(forecast_lines_consultant[0].consolidated_forecast, 0 - 4)
+        self.assertEqual(forecast_lines_consultant[1].consolidated_forecast, -0)
 
     def test_task_forecast_lines_consolidated_forecast_overallocation(self):
         with freeze_time("2022-01-01"):
@@ -707,6 +729,7 @@ def test_task_forecast_lines_consolidated_forecast_overallocation_multiple_tasks
                 -0.75,
             )
 
+    @freeze_time("2022-01-03 12:00:00")
     def test_task_forecast_lines_employee_different_roles(self):
         """
         Test forecast lines when employee has different roles.
@@ -773,6 +796,7 @@ def test_task_forecast_lines_employee_different_roles(self):
         self.assertAlmostEqual(forecast_pm.consolidated_forecast, 0.25)
         self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 0.25)
 
+    @freeze_time("2022-01-03 12:00:00")
     def test_task_forecast_lines_employee_main_role(self):
         """
         Test forecast lines when employee has different roles

From d696a91635705af689c544ee19229a34ff1a1602 Mon Sep 17 00:00:00 2001
From: ntsirintanis <ntsirintanis@therp.nl>
Date: Wed, 9 Nov 2022 15:03:19 +0100
Subject: [PATCH 24/33] [14.0][IMP][WIP] project_forecast_line: fix backport
 unit tests

---
 .../tests/test_forecast_line.py               | 28 +++++++++++++------
 1 file changed, 19 insertions(+), 9 deletions(-)

diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py
index 8f05e4902a..45117c503f 100644
--- a/project_forecast_line/tests/test_forecast_line.py
+++ b/project_forecast_line/tests/test_forecast_line.py
@@ -4,11 +4,11 @@
 
 from freezegun import freeze_time
 
-from odoo.tests.common import Form, TransactionCase, tagged
+from odoo.tests.common import Form, SavepointCase, tagged
 
 
 @tagged("-at_install", "post_install")
-class BaseForecastLineTest(TransactionCase):
+class BaseForecastLineTest(SavepointCase):
     @classmethod
     @freeze_time("2022-01-01")
     def setUpClass(cls):
@@ -369,6 +369,10 @@ def test_confirm_order_sale_order_create_project_task_with_forecast_line(self):
         so.action_confirm()
         line = so.order_line[0]
         task = self.env["project.task"].search([("sale_line_id", "=", line.id)])
+        # Give a project_status to the project
+        task.project_id.project_status = self.env.ref(
+            "project_status.project_status_in_progress"
+        )
         forecast_lines = self.env["forecast.line"].search(
             [("res_id", "=", task.id), ("res_model", "=", "project.task")]
         )
@@ -409,6 +413,10 @@ def test_timesheet_forecast_lines(self):
         with freeze_time("2022-02-14"):
             line = so.order_line[0]
             task = self.env["project.task"].search([("sale_line_id", "=", line.id)])
+            # Give a project_status to the project
+            task.project_id.project_status = self.env.ref(
+                "project_status.project_status_in_progress"
+            )
             # timesheet 1d
             self.env["account.analytic.line"].create(
                 {
@@ -530,8 +538,8 @@ def test_task_forecast_lines_consolidated_forecast(self):
         #
         # Projet 1 is in TODO (not confirmed forecast)
         project_1 = self.env["project.project"].create({"name": "TestProject1"})
-        # set project in stage "to do" to get forecast
-        project_1.stage_id = self.env.ref("project.project_project_stage_0")
+        # set project in stage "Pending" to get confirmed forecast
+        project_1.project_status = self.env.ref("project_status.project_status_pending")
         task_values = {
             "project_id": project_1.id,
             "forecast_role_id": self.role_consultant.id,
@@ -541,20 +549,22 @@ def test_task_forecast_lines_consolidated_forecast(self):
         }
         task_values.update({"name": "Task1"})
         task_1 = self.env["project.task"].create(task_values)
-        task_1.user_ids = self.user_consultant
+        task_1.user_id = self.user_consultant
         task_values.update({"name": "Task2"})
         task_2 = self.env["project.task"].create(task_values)
-        task_2.user_ids = self.user_consultant
+        task_2.user_id = self.user_consultant
 
         # Project 2 is in stage "in progress" to get forecast
         project_2 = self.env["project.project"].create({"name": "TestProject2"})
-        project_2.stage_id = self.env.ref("project.project_project_stage_1")
+        project_2.project_status = self.env.ref(
+            "project_status.project_status_in_progress"
+        )
         task_values.update({"project_id": project_2.id, "name": "Task3"})
         task_3 = self.env["project.task"].create(task_values)
-        task_3.user_ids = self.user_consultant
+        task_3.user_id = self.user_consultant
         task_values.update({"name": "Task4"})
         task_4 = self.env["project.task"].create(task_values)
-        task_4.user_ids = self.user_consultant
+        task_4.user_id = self.user_consultant
 
         # check forecast lines
         forecast = self.env["forecast.line"].search(

From 8f27174a66a104252b4eaea42c5e1904c6f079e4 Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Wed, 31 Aug 2022 17:13:22 +0200
Subject: [PATCH 25/33] [FIX] project_forecast_line consolidated capacity

in some cases, we could have capacity consumed by a task which was not matching
a work capacity line in the period because of a faulty optimisation we were making
which skipped the creation of lines with a capacity of 0 -> then we had no line on which
to compute the negative consolidated capacity.

We remove the optimisation to fix this case and show the problematic periods
in the consolidated capacity graphs

From 877e74fa5a4af781dc8438f041cab34af824019f Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Tue, 30 Aug 2022 11:39:39 +0300
Subject: [PATCH 26/33] [15.0][FIX] project_forecast_line: take roles into
 account on calc


From f58ceb1234b5433ce7efb6cb306486125fc289d4 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Mon, 5 Sep 2022 11:03:52 +0300
Subject: [PATCH 27/33] [15.0][IMP] project_forecast_line: setting to control
 consumption states


From a01987b839d3745445c68e36c3a0733a9dd6954d Mon Sep 17 00:00:00 2001
From: Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Date: Wed, 31 Aug 2022 17:13:22 +0200
Subject: [PATCH 28/33] [FIX] project_forecast_line consolidated capacity

in some cases, we could have capacity consumed by a task which was not matching
a work capacity line in the period because of a faulty optimisation we were making
which skipped the creation of lines with a capacity of 0 -> then we had no line on which
to compute the negative consolidated capacity.

We remove the optimisation to fix this case and show the problematic periods
in the consolidated capacity graphs

From 3c08a467627e0d8a822bfabb9149b4dfd067dce9 Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Tue, 30 Aug 2022 11:39:39 +0300
Subject: [PATCH 29/33] [15.0][FIX] project_forecast_line: take roles into
 account on calc


From 630c358a17833e8499648b9bbe36bde64485673c Mon Sep 17 00:00:00 2001
From: Maksym Yankin <yankinmk@gmail.com>
Date: Mon, 5 Sep 2022 11:03:52 +0300
Subject: [PATCH 30/33] [15.0][IMP] project_forecast_line: setting to control
 consumption states


From 759e42c1ccff1a5095035c77098b19e363e9826b Mon Sep 17 00:00:00 2001
From: Tom Blauwendraat <tom@sunflowerweb.nl>
Date: Wed, 14 Dec 2022 19:57:59 +0100
Subject: [PATCH 31/33] [FIX] This fails when the field is being read by a user
 with readonly access because then rec is a hr.employee.public record

---
 project_forecast_line/models/hr_employee.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py
index 9afb519899..b2398d9b29 100644
--- a/project_forecast_line/models/hr_employee.py
+++ b/project_forecast_line/models/hr_employee.py
@@ -24,6 +24,9 @@ def _compute_main_role_id(self):
         # can"t store as it depends on current date
         today = fields.Date.context_today(self)
         for rec in self:
+            if not hasattr(rec, 'role_ids'):
+                rec.main_role_id = None
+                continue
             rec.main_role_id = rec.role_ids.filtered(
                 lambda r: r.date_start <= today
                 and not r.date_end

From 935a0e851f10043d9c12b595b482ea85f4c3ef2e Mon Sep 17 00:00:00 2001
From: Tom Blauwendraat <tom@sunflowerweb.nl>
Date: Wed, 14 Dec 2022 20:02:04 +0100
Subject: [PATCH 32/33] fixup! [FIX] This fails when the field is being read by
 a user with readonly access because then rec is a hr.employee.public record

---
 project_forecast_line/models/hr_employee.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py
index b2398d9b29..2d9ef31554 100644
--- a/project_forecast_line/models/hr_employee.py
+++ b/project_forecast_line/models/hr_employee.py
@@ -24,7 +24,7 @@ def _compute_main_role_id(self):
         # can"t store as it depends on current date
         today = fields.Date.context_today(self)
         for rec in self:
-            if not hasattr(rec, 'role_ids'):
+            if not hasattr(rec, "role_ids"):
                 rec.main_role_id = None
                 continue
             rec.main_role_id = rec.role_ids.filtered(

From 1cfd05e66f5b6da3d9d322d27edf90099f983666 Mon Sep 17 00:00:00 2001
From: Tom Blauwendraat <tom@sunflowerweb.nl>
Date: Wed, 14 Dec 2022 20:09:40 +0100
Subject: [PATCH 33/33] fixup! fixup! [FIX] This fails when the field is being
 read by a user with readonly access because then rec is a hr.employee.public
 record

---
 project_forecast_line/models/hr_employee.py | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/project_forecast_line/models/hr_employee.py b/project_forecast_line/models/hr_employee.py
index 2d9ef31554..451338ccd9 100644
--- a/project_forecast_line/models/hr_employee.py
+++ b/project_forecast_line/models/hr_employee.py
@@ -17,16 +17,14 @@ class HrEmployee(models.Model):
 
     role_ids = fields.One2many("hr.employee.forecast.role", "employee_id")
     main_role_id = fields.Many2one(
-        "forecast.role", compute="_compute_main_role_id", ondelete="restrict"
+        "forecast.role", compute="_compute_main_role_id", ondelete="restrict",
+        compute_sudo=True
     )
 
     def _compute_main_role_id(self):
         # can"t store as it depends on current date
         today = fields.Date.context_today(self)
         for rec in self:
-            if not hasattr(rec, "role_ids"):
-                rec.main_role_id = None
-                continue
             rec.main_role_id = rec.role_ids.filtered(
                 lambda r: r.date_start <= today
                 and not r.date_end