diff --git a/project_forecast_line/README.rst b/project_forecast_line/README.rst new file mode 100644 index 0000000000..87c7d75b27 --- /dev/null +++ b/project_forecast_line/README.rst @@ -0,0 +1,148 @@ +====================== +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. + +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 +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp SA + +Contributors +~~~~~~~~~~~~ + +* Alexandre Fayolle +* Maksym Yankin + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/project `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/project_forecast_line/__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..b9d137e50b --- /dev/null +++ b/project_forecast_line/__manifest__.py @@ -0,0 +1,29 @@ +# 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": "14.0.1.0.3", + "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", "project_status"], + "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_status_views.xml", + "views/res_config_settings_views.xml", + "data/ir_cron.xml", + "data/project_status.xml", + ], + "installable": True, + "development_status": "Alpha", + "application": True, +} diff --git a/project_forecast_line/data/ir_cron.xml b/project_forecast_line/data/ir_cron.xml new file mode 100644 index 0000000000..21563c6143 --- /dev/null +++ b/project_forecast_line/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + Forecast recomputation + + + code + model._cron_recompute_all() + 1 + days + -1 + + + diff --git a/project_forecast_line/data/project_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 @@ + + + + + forecast + + + + confirmed + + + diff --git a/project_forecast_line/i18n/fr.po b/project_forecast_line/i18n/fr.po new file mode 100644 index 0000000000..2d563eb6ac --- /dev/null +++ b/project_forecast_line/i18n/fr.po @@ -0,0 +1,534 @@ +# 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.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" +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_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" +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.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 +#: 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.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" +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_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" +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..ebf02c37ed --- /dev/null +++ b/project_forecast_line/i18n/project_forecast_line.pot @@ -0,0 +1,499 @@ +# 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\n" +"Report-Msgid-Bugs-To: \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.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" +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_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" +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.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 +#: 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.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" +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_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" +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..da3eba8fb0 --- /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 +from . import project_status 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..c192239b67 --- /dev/null +++ b/project_forecast_line/models/forecast_line.py @@ -0,0 +1,398 @@ +# 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, + ondelete="restrict", + ) + 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( + 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 + ) + 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") + 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)), + ("type", "=", "confirmed"), + ] + ) + else: + lines = self.env["forecast.line"] + capacities = {} + for line in lines: + 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 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 + ) + 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 + + 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 + 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): + grouped_lines_values = self._get_grouped_line_values() + for rec in self: + if rec.res_model != "hr.employee.forecast.role": + rec.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 = ( + resource_forecast.get("confirmed", 0) if resource_forecast else 0 + ) + unconfirmed = ( + confirmed + resource_forecast.get("forecast", 0) + if resource_forecast + else 0 + ) + rec.consolidated_forecast = self._convert_hours_to_days( + rec.forecast_hours + unconfirmed + ) + rec.confirmed_consolidated_forecast = self._convert_hours_to_days( + rec.forecast_hours + confirmed + ) + + 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, + ) + # 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 = { + "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() + + @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/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..451338ccd9 --- /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", 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", 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: + 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"] = [(6, 0, job.role_id.ids)] + 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..0eafedb057 --- /dev/null +++ b/project_forecast_line/models/hr_leave.py @@ -0,0 +1,85 @@ +# 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")]) + # 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)", + 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, + employee_id=leave.employee_id.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_id.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..fb9874266a --- /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", ondelete="restrict") 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 new file mode 100644 index 0000000000..6cc02cb507 --- /dev/null +++ b/project_forecast_line/models/project_task.py @@ -0,0 +1,159 @@ +# 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", ondelete="restrict") + 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 vals.get("planned_date_begin"): + vals["forecast_date_planned_start"] = vals["planned_date_begin"] + if vals.get("planned_date_end"): + 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_id", + ] + + 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_id") + def onchange_user_id(self): + for task in self: + if not task.user_id: + continue + if task.forecast_role_id: + continue + 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) + 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.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 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": + _logger.info("skip task %s: cancelled sale", task) + continue + 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_id.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/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/CONTRIBUTORS.rst b/project_forecast_line/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..ed6bc85941 --- /dev/null +++ b/project_forecast_line/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Alexandre Fayolle +* Maksym Yankin diff --git a/project_forecast_line/readme/DESCRIPTION.rst b/project_forecast_line/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..d86f5d98b8 --- /dev/null +++ b/project_forecast_line/readme/DESCRIPTION.rst @@ -0,0 +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. + +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/security/forecast_line_security.xml b/project_forecast_line/security/forecast_line_security.xml new file mode 100644 index 0000000000..bd57ee533d --- /dev/null +++ b/project_forecast_line/security/forecast_line_security.xml @@ -0,0 +1,13 @@ + + + + Manage Forecast Dates on Quotations + + + + + Forecast multi-company + + [('company_id', 'in', company_ids)] + + diff --git a/project_forecast_line/security/ir.model.access.csv b/project_forecast_line/security/ir.model.access.csv new file mode 100644 index 0000000000..d6690ba17f --- /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_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_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 diff --git a/project_forecast_line/static/description/icon.png b/project_forecast_line/static/description/icon.png new file mode 100644 index 0000000000..ebd3d6aada Binary files /dev/null and b/project_forecast_line/static/description/icon.png differ diff --git a/project_forecast_line/static/description/index.html b/project_forecast_line/static/description/index.html new file mode 100644 index 0000000000..29058b0689 --- /dev/null +++ b/project_forecast_line/static/description/index.html @@ -0,0 +1,481 @@ + + + + + + +Project Forecast Lines + + + +
+

Project Forecast Lines

+ + +

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

+

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.

+

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

+ +
+

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

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp SA
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/project project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/project_forecast_line/tests/__init__.py b/project_forecast_line/tests/__init__.py new file mode 100644 index 0000000000..93b26a9498 --- /dev/null +++ b/project_forecast_line/tests/__init__.py @@ -0,0 +1 @@ +from . import test_forecast_line diff --git a/project_forecast_line/tests/test_forecast_line.py b/project_forecast_line/tests/test_forecast_line.py new file mode 100644 index 0000000000..45117c503f --- /dev/null +++ b/project_forecast_line/tests/test_forecast_line.py @@ -0,0 +1,875 @@ +# 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, SavepointCase, tagged + + +@tagged("-at_install", "post_install") +class BaseForecastLineTest(SavepointCase): + @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", + "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", + "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", + "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)]) + # 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")] + ) + 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)]) + # 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( + { + "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 _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 + + @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, + "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 + + # 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 "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, + "forecast_date_planned_start": "2022-02-14", + "forecast_date_planned_end": "2022-02-14", + "planned_hours": 8, + } + task_values.update({"name": "Task1"}) + task_1 = self.env["project.task"].create(task_values) + task_1.user_id = self.user_consultant + task_values.update({"name": "Task2"}) + task_2 = self.env["project.task"].create(task_values) + 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.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_id = self.user_consultant + task_values.update({"name": "Task4"}) + task_4 = self.env["project.task"].create(task_values) + task_4.user_id = 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) + self.assertEqual( + forecast.mapped("forecast_hours"), + [ + -8.0, + ] + * 4, + ) + # 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, + ) + self.assertEqual( + forecast.filtered(lambda r: r.type == "confirmed").mapped( + "confirmed_consolidated_forecast" + ), + [1.0] * 2, + ) + forecast_consultant, forecast_pm = self._get_employee_forecast() + self.assertEqual(forecast_consultant.forecast_hours, 6.0) + self.assertAlmostEqual( + 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) + self.assertAlmostEqual(forecast_pm.confirmed_consolidated_forecast, 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"), + ] + ) + # 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_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"): + 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": 8, + } + ) + task.remaining_hours = 10 + 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, -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, + ): + 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" + ) + 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_id = 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_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, + forecast2.employee_resource_forecast_line_id, + ) + self.assertAlmostEqual( + forecast1.employee_resource_forecast_line_id.consolidated_forecast, + -0.75, + ) + self.assertAlmostEqual( + forecast1.employee_resource_forecast_line_id.confirmed_consolidated_forecast, + -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. + + 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.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) + 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_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 + self.assertEqual(task_forecast.forecast_hours, -8.0) + self.assertEqual(task_forecast.consolidated_forecast, 1.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) + 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) + + @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 + 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.project_status = self.env.ref( + "project_status.project_status_in_progress" + ) + 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_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 + self.assertEqual(task_forecast.forecast_hours, -8.0) + self.assertEqual(task_forecast.consolidated_forecast, 1.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) + 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 new file mode 100644 index 0000000000..a9238b8bdd --- /dev/null +++ b/project_forecast_line/views/forecast_line_views.xml @@ -0,0 +1,158 @@ + + + + forecast.line + + + + + + + + + + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + forecast.line + + + + + + + + + + + + + + + Forecast + ir.actions.act_window + forecast.line + tree,graph,pivot + + + Forecast (Consolidated) + ir.actions.act_window + forecast.line + graph,pivot,tree + + + [('employee_id', '!=', False)] + {'graph_groupbys': ['date_from:month', 'project_id']} + + + + + + + diff --git a/project_forecast_line/views/forecast_role_views.xml b/project_forecast_line/views/forecast_role_views.xml new file mode 100644 index 0000000000..a3b829945a --- /dev/null +++ b/project_forecast_line/views/forecast_role_views.xml @@ -0,0 +1,48 @@ + + + + forecast.role + + + + + + + + + forecast.role + + + + + + + + forecast.role + +
+ + + + + + +
+
+
+ + Forecast Roles + ir.actions.act_window + forecast.role + tree,form + + + + +
diff --git a/project_forecast_line/views/hr_employee_views.xml b/project_forecast_line/views/hr_employee_views.xml new file mode 100644 index 0000000000..afbad9594a --- /dev/null +++ b/project_forecast_line/views/hr_employee_views.xml @@ -0,0 +1,32 @@ + + + + hr.employee + + + + + + + + + + + + + + + + + + + + hr.job + + + + + + + + diff --git a/project_forecast_line/views/product_views.xml b/project_forecast_line/views/product_views.xml new file mode 100644 index 0000000000..66db9d076c --- /dev/null +++ b/project_forecast_line/views/product_views.xml @@ -0,0 +1,12 @@ + + + + product.template + + + + + + + + diff --git a/project_forecast_line/views/project_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 @@ + + + + project.status + + + + + + + + + diff --git a/project_forecast_line/views/project_task_views.xml b/project_forecast_line/views/project_task_views.xml new file mode 100644 index 0000000000..3b45b166f4 --- /dev/null +++ b/project_forecast_line/views/project_task_views.xml @@ -0,0 +1,16 @@ + + + + project.task + + + + + + + + + + + + diff --git a/project_forecast_line/views/res_config_settings_views.xml b/project_forecast_line/views/res_config_settings_views.xml new file mode 100644 index 0000000000..e273587afb --- /dev/null +++ b/project_forecast_line/views/res_config_settings_views.xml @@ -0,0 +1,78 @@ + + + + res.config.settings.view.form.inherit.forecast + res.config.settings + + + +
+

Forecast Management

+
+
+
+ +
+
+
+
+
+
+
+
+
+ + Settings + ir.actions.act_window + res.config.settings + form + inline + {'module': 'forecast', 'bin_size': False} + + + +
diff --git a/project_forecast_line/views/sale_order_views.xml b/project_forecast_line/views/sale_order_views.xml new file mode 100644 index 0000000000..bb6b0149ca --- /dev/null +++ b/project_forecast_line/views/sale_order_views.xml @@ -0,0 +1,48 @@ + + + + sale.order + + + + + + + + + + + + + + + + + + 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, +)