diff --git a/project_fte/README.rst b/project_fte/README.rst new file mode 100644 index 0000000000..7b777a4407 --- /dev/null +++ b/project_fte/README.rst @@ -0,0 +1,156 @@ +=========== +Project FTE +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a7f1bc48b07ef5a995fd3ac2b703dd2fd5451fd0dbd60fc515b77b46180357b3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/17.0/project_fte + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-17-0/project-17-0-project_fte + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/project&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +With this module you can manage FTE (Full-Time Equivalent) contracts and +evolution in projects. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. **Go to the desired project**, click the three dots (⋯) in the + top-right corner, and select **Settings**. +2. A new section named **FTE** will appear in the project form. +3. Click the **Generate FTE Lines** button to open the wizard. + +🔧 In the wizard: +~~~~~~~~~~~~~~~~~ + +- Select the **Start Date**. This date will be stored on the project. +- **total FTE hours** will be calculated from the total of the + allocated_hours of the tasks. +- Fill in the **Monthly Hours** manually. These represent the typical + number of hours to distribute per month, and are used to calculate + the duration. +- Define the **Profile Distribution** by selecting the roles and + specifying the number of hours per role. + +.. + + 💡 If your roles have a **Price per Hour** defined (on the role + itself), the wizard will use it to compute the cost of each role’s + hours. + +- The wizard will automatically compute: + + - The **percentage** of each role's hours, + - The **Monthly Amount** and **Total Amount**, + - The **End Date**, based on the total hours and monthly + distribution. + +-------------- + +🎯 Autofill from Milestones +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instead of filling the profile distribution manually, you can click +**Load from Milestones**: + +- This will gather all project tasks linked to milestones, +- And group their allocated hours by the **role assigned to each + milestone**, +- Creating a profile distribution automatically. + +This is useful when project planning has already been done using +milestones. + +-------------- + +📆 Generating the Lines +~~~~~~~~~~~~~~~~~~~~~~~ + +Once all fields are filled: + +1. Click **Generate**. +2. FTE lines will be created month by month from the selected Start Date + to the computed End Date. +3. The hours and costs are distributed according to the profile + distribution. + +-------------- + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* APSL Nagarro + +Contributors +------------ + +- ``APSL-Nagarro ``\ \_: + + - Miquel Alzanillas miquel.alzanillas@nagarro.com + - Miquel Pascual miquel.pascual@nagarro.com + - Bernat Obrador bernat.obrador@nagarro.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-miquelalzanillas| image:: https://github.com/miquelalzanillas.png?size=40px + :target: https://github.com/miquelalzanillas + :alt: miquelalzanillas +.. |maintainer-mpascuall| image:: https://github.com/mpascuall.png?size=40px + :target: https://github.com/mpascuall + :alt: mpascuall + +Current `maintainers `__: + +|maintainer-miquelalzanillas| |maintainer-mpascuall| + +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_fte/__init__.py b/project_fte/__init__.py new file mode 100644 index 0000000000..9b4296142f --- /dev/null +++ b/project_fte/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/project_fte/__manifest__.py b/project_fte/__manifest__.py new file mode 100644 index 0000000000..0eeb156d56 --- /dev/null +++ b/project_fte/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2025 APSL Nagarro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Project FTE", + "summary": "Manage FTE (Full-Time Equivalent) contracts and evolution in projects.", + "version": "17.0.1.0.0", + "category": "Project", + "website": "https://github.com/OCA/project", + "author": "APSL Nagarro, Odoo Community Association (OCA)", + "maintainers": ["miquelalzanillas", "mpascuall"], + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "project", + "project_role", + "hr_timesheet", + "hr_timesheet_type_non_billable", + ], + "data": [ + "security/ir.model.access.csv", + "data/templates/mail_template.xml", + "data/ir_cron.xml", + "wizard/project_fte_mass_generator_views.xml", + "views/project_fte_month_line_views.xml", + "views/project_project_views.xml", + "views/project_role.xml", + "views/project_milestone.xml", + ], +} diff --git a/project_fte/data/ir_cron.xml b/project_fte/data/ir_cron.xml new file mode 100644 index 0000000000..8d04a2dbb9 --- /dev/null +++ b/project_fte/data/ir_cron.xml @@ -0,0 +1,12 @@ + + + FTE Execution Monitoring + + code + model._cron_check_fte_execution() + 1 + days + -1 + + + diff --git a/project_fte/data/templates/mail_template.xml b/project_fte/data/templates/mail_template.xml new file mode 100644 index 0000000000..8f53f770a0 --- /dev/null +++ b/project_fte/data/templates/mail_template.xml @@ -0,0 +1,96 @@ + + + + + + FTE Execution Warning + + ⚠ FTE Execution Alert: {{object.project_id.name}} + {{object.project_id.company_id.email or ''}} + {{object.project_id.user_id.email}} + {{object.project_id.user_id.lang}} + + +
+
+ +

⚠ Low FTE Execution

+ +

Dear ,

+ +

+ The FTE execution for project + in / is: +

+ +
    +
  • expected hours
  • +
  • hours executed
  • +
  • % of execution
  • +
+ +

This is below the expected progress. Please review the execution and make sure timesheets are properly filled in.

+ + + +

Best regards,
Odoo System

+
+
+ ]]> +
+ +
+ + + + FTE Execution Overload Warning + + ⚠ Excess FTE Execution: {{object.project_id.name}} + {{object.project_id.company_id.email or ''}} + {{object.project_id.user_id.email}} + {{object.project_id.user_id.lang}} + + +
+
+ +

⚠ Excessive FTE Execution

+ +

Dear ,

+ +

+ The FTE execution for project + in / is: +

+ +
    +
  • expected hours
  • +
  • hours executed
  • +
  • % of execution
  • +
+ +

This exceeds the expected progress. Please review the timesheets to confirm accurate reporting.

+ + + +

Best regards,
Odoo System

+
+
+ ]]> +
+ +
+ +
diff --git a/project_fte/i18n/es.po b/project_fte/i18n/es.po new file mode 100644 index 0000000000..15cf080a00 --- /dev/null +++ b/project_fte/i18n/es.po @@ -0,0 +1,1043 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_fte +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-11 09:11+0000\n" +"PO-Revision-Date: 2025-08-11 09:11+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: project_fte +#: model:mail.template,body_html:project_fte.mail_template_fte_execution_overload +msgid "" +"\n" +" \n" +" \n" +"
\n" +"
\n" +" \n" +"

⚠ Excessive FTE Execution

\n" +"\n" +"

Dear ,

\n" +"\n" +"

\n" +" The FTE execution for project \n" +" in / is:\n" +"

\n" +"\n" +"
    \n" +"
  • expected hours
  • \n" +"
  • hours executed
  • \n" +"
  • % of execution
  • \n" +"
\n" +"\n" +"

This exceeds the expected progress. Please review the timesheets to confirm accurate reporting.

\n" +"\n" +" \n" +"\n" +"

Best regards,
Odoo System

\n" +"
\n" +"
\n" +" \n" +" " +msgstr "" +"\n" +" \n" +" \n" +"
\n" +"
\n" +" \n" +"

⚠ Ejecución de FTE Excesiva

\n" +"\n" +"

Estimado/a ,

\n" +"\n" +"

\n" +" La ejecución de FTE para el proyecto \n" +" en / es:\n" +"

\n" +"\n" +"
    \n" +"
  • horas esperadas
  • \n" +"
  • horas ejecutadas
  • \n" +"
  • % de ejecución
  • \n" +"
\n" +"\n" +"

Esto excede el progreso esperado. Por favor, revise las hojas de tiempo para confirmar que los datos sean correctos.

\n" +"\n" +" \n" +"\n" +"

Saludos cordiales,
Sistema Odoo

\n" +"
\n" +"
\n" +" \n" +" " + +#. module: project_fte +#: model:mail.template,body_html:project_fte.mail_template_fte_execution_warning +msgid "" +"\n" +" \n" +" \n" +"
\n" +"
\n" +" \n" +"

⚠ Low FTE Execution

\n" +"\n" +"

Dear ,

\n" +"\n" +"

\n" +" The FTE execution for project \n" +" in / is:\n" +"

\n" +"\n" +"
    \n" +"
  • expected hours
  • \n" +"
  • hours executed
  • \n" +"
  • % of execution
  • \n" +"
\n" +"\n" +"

This is below the expected progress. Please review the execution and make sure timesheets are properly filled in.

\n" +"\n" +" \n" +"\n" +"

Best regards,
Odoo System

\n" +"
\n" +"
\n" +" \n" +" " +msgstr "" +"\n" +" \n" +" \n" +"
\n" +"
\n" +" \n" +"

⚠ Ejecución FTE Insuficiente

\n" +"\n" +"

Estimado/a ,

\n" +"\n" +"

\n" +" La ejecución de FTE para el proyecto \n" +" en / es:\n" +"

\n" +"\n" +"
    \n" +"
  • horas esperadas
  • \n" +"
  • horas ejecutadas
  • \n" +"
  • % de ejecución
  • \n" +"
\n" +"\n" +"

Esto está por debajo del progreso esperado. Por favor, revise la ejecución y asegúrese de que las hojas de tiempo estén correctamente completadas.

\n" +"\n" +" \n" +"\n" +"

Saludos cordiales,
Sistema Odoo

\n" +"
\n" +"
\n" +" \n" +" " + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_kanban_inherit_fte +msgid "FTE" +msgstr "" + +#. module: project_fte +#: model:ir.model.constraint,message:project_fte.constraint_project_fte_month_line_project_month_year_uniq +msgid "A line for this month and year already exists for this project." +msgstr "En este proyecto ya existe una línea para este mes y año." + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_needaction +msgid "Action Needed" +msgstr "Acción Necesaria" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_ids +msgid "Activities" +msgstr "Actividades" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "Decoración de Excepción de Actividad" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_state +msgid "Activity State" +msgstr "Estado de la Actividad" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_type_icon +msgid "Activity Type Icon" +msgstr "Ícono del Tipo de Actividad" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_project__allocated_hours +msgid "Allocated Hours" +msgstr "Horas asignadas" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__profile_price_amount +msgid "Amount" +msgstr "Importe" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Amounts Summary" +msgstr "Resumen de Importes" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_account_analytic_line +msgid "Analytic Line" +msgstr "Línea analítica" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__4 +msgid "April" +msgstr "Abril" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_attachment_count +msgid "Attachment Count" +msgstr "Cantidad de Archivos Adjuntos" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__8 +msgid "August" +msgstr "Agosto" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "Both Start Date and End Date must be set." +msgstr "La Fecha de Inicio y la Fecha de Fin tienen que estar establecidas" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +msgid "Cancel" +msgstr "Cancelar" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Close FTE" +msgstr "Cerrar FTE" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Copy last FTE Line" +msgstr "Copiar Última Línea FTE" + +#. module: project_fte +#: model_terms:ir.actions.act_window,help:project_fte.project_fte_month_line_action +msgid "Create a new FTE line for your project." +msgstr "Crea una nueva línea de FTE para tu proyecto." + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__create_uid +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__create_uid +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__create_uid +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__create_date +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__create_date +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__create_date +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__currency_id +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__currency_id +#: model:ir.model.fields,field_description:project_fte.field_project_project__currency_id +msgid "Currency" +msgstr "Moneda" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__12 +msgid "December" +msgstr "Diciembre" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Delete FTE" +msgstr "Eliminar FTE" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +msgid "Discount" +msgstr "Descuento" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__discount +#: model:ir.model.fields,field_description:project_fte.field_project_project__discount +msgid "Discount (%)" +msgstr "Descuento (%)" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__discount_amount +#: model:ir.model.fields,field_description:project_fte.field_project_project__discount_amount +msgid "Discount Amount" +msgstr "Importe del Descuento" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__display_name +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__display_name +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__display_name +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__display_name +msgid "Display Name" +msgstr "Nombre" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__date_to +msgid "End Date" +msgstr "Fecha de Fin" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__executed_hours +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_month_line_view_tree +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Executed Hours" +msgstr "Horas Ejecutadas" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__executed_percent +msgid "Executed Percent" +msgstr "Porcentaje Ejecutado" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__high_usage_sent +msgid "Execution High Usage Sent" +msgstr "Notificación por Alta Ejecución Enviada" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__overload_sent +msgid "Execution Overload Sent" +msgstr "Notificación por Sobrecarga Enviada" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__warning_sent +msgid "Execution Warning Sent" +msgstr "Notificación por Baja Ejecución Enviada" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "" +"Existing FTE lines found for the following months: %s. Please adjust the " +"date range." +msgstr "Se han encontrado líneas FTE existentes para los siguientes meses: %s. Ajuste el rango de fechas." + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "FTE" +msgstr "" + +#. module: project_fte +#: model:ir.actions.server,name:project_fte.ir_cron_check_fte_execution_ir_actions_server +msgid "FTE Execution Monitoring" +msgstr "Monitoreo de Ejecución FTE" + +#. module: project_fte +#: model:mail.template,name:project_fte.mail_template_fte_execution_overload +msgid "FTE Execution Overload Warning" +msgstr "Aviso de Sobrecarga en la Ejecución FTE" + +#. module: project_fte +#: model:mail.template,name:project_fte.mail_template_fte_execution_warning +msgid "FTE Execution Warning" +msgstr "Aviso de Ejecución FTE Insuficiente" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__wizard_id +msgid "FTE Mass Generator Wizard" +msgstr "Wizard de generación FTE Masivo" + +#. module: project_fte +#: model:ir.actions.act_window,name:project_fte.project_fte_month_line_action +#: model:ir.model.fields,field_description:project_fte.field_project_project__fte_month_line_ids +msgid "FTE Month Lines" +msgstr "Líneas de Mes FTE" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "FTE Month Table" +msgstr "Tabla de mes FTE" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__fte_months +#: model:ir.model.fields,field_description:project_fte.field_project_project__fte_months +msgid "FTE Months" +msgstr "Meses FTE" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__2 +msgid "February" +msgstr "Febrero" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_follower_ids +msgid "Followers" +msgstr "Seguidores" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_partner_ids +msgid "Followers (Partners)" +msgstr "Seguidores (Contactos)" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "Ícono de font awesome, por ejemplo: fa-tasks" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +msgid "Generate" +msgstr "Generar" + +#. module: project_fte +#: model:ir.actions.act_window,name:project_fte.project_fte_mass_generator_action +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Generate FTE Lines" +msgstr "Generar Líneas FTE" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__has_message +msgid "Has Message" +msgstr "Tiene Mensaje" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__id +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__id +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__id +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__id +msgid "ID" +msgstr "" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_exception_icon +msgid "Icon" +msgstr "Icono" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "Ícono que indica una actividad con excepción." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__overwrite_existing +msgid "" +"If checked, any existing FTE lines for the selected months will be deleted " +"and recreated." +msgstr "" +"Si está marcado, cualquier Líne FTE existente para el mes seleccionado va a " +"ser recreada." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__message_needaction +msgid "If checked, new messages require your attention." +msgstr "Si está marcado, hay mensajes nuevos que requieren tu atención." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__message_has_error +msgid "If checked, some messages have a delivery error." +msgstr "Si está marcado, algunos mensajes tienen un error de entrega." + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_is_follower +msgid "Is Follower" +msgstr "Es seguidor" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_project__is_fte_closed +msgid "Is Fte Closed" +msgstr "Esta el FTE cerrado" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__1 +msgid "January" +msgstr "Enero" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__7 +msgid "July" +msgstr "Julio" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__6 +msgid "June" +msgstr "Junio" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__write_uid +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__write_uid +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__write_uid +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__write_date +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__write_date +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__write_date +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +msgid "Load from Milestones" +msgstr "Cargar desde Hitos" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__3 +msgid "March" +msgstr "Marzo" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +msgid "Mass Generate FTE Lines" +msgstr "Generar Líneas FTE Masivamente" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__5 +msgid "May" +msgstr "Mayo" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_has_error +msgid "Message Delivery error" +msgstr "Error en la entrega del mensaje" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_ids +msgid "Messages" +msgstr "Mensajes" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "" +"Milestone '%s' does not have an associated role.\n" +" Please assign a role to the milestone\n" +" before generating FTE lines from milestones" +msgstr "" +"El hito '%s' no tiene un rol asociado.\n" +" Por favor, asigna un rol al hito\n" +" antes de generar líneas FTE desde los hitos" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__month +msgid "Month" +msgstr "Mes" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__month_amount +msgid "Month Amount" +msgstr "Total sin descuento" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__month_discount_amount +msgid "Month Discount Amount" +msgstr "Importe del Descuento" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__month_line_id +msgid "Month Line" +msgstr "Línea de Mes" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__month_int +msgid "Month Number" +msgstr "Número de Mes" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__month_raw_amount +msgid "Month Raw Amount" +msgstr "Total sin descuento" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__monthly_amount +#: model:ir.model.fields,field_description:project_fte.field_project_project__monthly_amount +msgid "Monthly Amount" +msgstr "Total mensual" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_project__monthly_discount_amount +msgid "Monthly Discount Amount" +msgstr "Descuento total por mes" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_project__monthly_html_table +msgid "Monthly FTE Breakdown" +msgstr "Desglose mensual de FTE" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__monthly_hours +msgid "Monthly Hours" +msgstr "Horas Mensuales" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_project__monthly_raw_amount +msgid "Monthly Raw Amount" +msgstr "Total sin descuento" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "Monthly hours to allocate must be greater than zero." +msgstr "Las Horas Mensuales deben ser mayor a 0" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Montly Summary" +msgstr "Resumen Mensual" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "Fecha límite de mi actividad" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__name +msgid "Name" +msgstr "Nombre" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/models/project_fte_month_line.py:0 +#, python-format +msgid "New" +msgstr "Nuevo" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "Fecha límite de la próxima actividad" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_summary +msgid "Next Activity Summary" +msgstr "Resumen de la próxima actividad" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_type_id +msgid "Next Activity Type" +msgstr "Tipo de la próxima actividad" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__11 +msgid "November" +msgstr "Noviembre" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_needaction_counter +msgid "Number of Actions" +msgstr "Número de acciones" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__message_has_error_counter +msgid "Number of errors" +msgstr "Número de errores" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "Número de mensajes que requieren acción" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "Número de mensajes con error de entrega" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__fte_months +#: model:ir.model.fields,help:project_fte.field_project_project__fte_months +msgid "Number of months for which FTE lines will be generated." +msgstr "Número de meses para los que se generarán líneas FTE." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__month_int +msgid "Numeric value of the month for proper sorting." +msgstr "Valor númerico del mes" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__10 +msgid "October" +msgstr "Octubre" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__overwrite_existing +msgid "Overwrite Existing Lines" +msgstr "Sobreescribir Líneas Existentes" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__profile_hours_percentage +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__profile_hours_percentage +msgid "Percentage" +msgstr "Porcentaje" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__discount +#: model:ir.model.fields,help:project_fte.field_project_project__discount +msgid "Percentage discount applied to the total amount." +msgstr "Porcentaje de descuento aplicado al monto total." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__executed_percent +msgid "Percentage of executed hours compared to FTE hours." +msgstr "Porcentaje de horas ejecutadas en relación con las horas FTE esperadas." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator_profile__profile_hours_percentage +#: model:ir.model.fields,help:project_fte.field_project_fte_profile_distribution__profile_hours_percentage +msgid "Percentage of this profile's hours over the total for the month." +msgstr "Porcentaje de las horas del perfil sobre el total del mes." + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_project__previous_monthly_hours +msgid "Previous Monthly Hours" +msgstr "Horas Mensuales Previas" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__profile_price_hour +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__profile_price_hour +#: model:ir.model.fields,field_description:project_fte.field_project_role__price_hour +msgid "Price per Hour" +msgstr "Precio por hora" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator_profile__profile_price_hour +#: model:ir.model.fields,help:project_fte.field_project_fte_profile_distribution__profile_price_hour +msgid "Price per hour for this profile." +msgstr "Precio por hora para este perfil" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_role__price_hour +msgid "Price per hour for this role." +msgstr "Precio por hora para este rol" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__profile_distribution_ids +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__profile_distribution_ids +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_month_line_view_form +msgid "Profile Distribution" +msgstr "Distribución por Perfil" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_fte_mass_generator_profile +msgid "Profile Distribution for Mass FTE Generator Wizard" +msgstr "Distribución por Perfil para el Wizard de Generación Masiva de FTE" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__profile_hours +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__profile_hours +msgid "Profile Hours" +msgstr "Horas del Perfil" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator_profile__role_id +#: model:ir.model.fields,field_description:project_fte.field_project_fte_profile_distribution__role_id +msgid "Profile/Role" +msgstr "Perfil / Rol" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_project +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__project_id +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__project_id +msgid "Project" +msgstr "Proyecto" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_fte_month_line +msgid "Project FTE Month Line" +msgstr "Línea FTE del Mes del proyecto" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_fte_profile_distribution +msgid "Project FTE Profile Distribution" +msgstr "Distribución FTE del Perfil por proyecto" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_milestone +msgid "Project Milestone" +msgstr "Hito de proyecto" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_role +#: model:ir.model.fields,field_description:project_fte.field_project_milestone__project_role_id +msgid "Project Role" +msgstr "Rol del Proyecto" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__rating_ids +msgid "Ratings" +msgstr "Calificaciones" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Reopen FTE" +msgstr "Reabrir FTE" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__activity_user_id +msgid "Responsible User" +msgstr "Responsable" + +#. module: project_fte +#: model:ir.model.fields.selection,name:project_fte.selection__project_fte_month_line__month__9 +msgid "September" +msgstr "Septiembre" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__date_from +msgid "Start Date" +msgstr "Fecha de Inicio" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"Estado basado en actividades\n" +"Vencida: La fecha de vencimiento ya ha pasado\n" +"Hoy: La fecha de la actividad es hoy\n" +"Planificada: Actividades futuras." + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "" +"Task '%s' does not have an associated milestone.\n" +" Please assign a milestone to the task\n" +" before generating FTE lines from milestones" +msgstr "" +"La tarea '%s' no tiene un hito asociado.\n" +" Por favor, asigna un hito a la tarea\n" +" antes de generar líneas FTE desde los hitos" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "The role '%s' has already been selected, please choose another one" +msgstr "El rol '%s' ya ha sido seleccionado. Por favor, elige otro" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "" +"The total monthly hours for profiles (%(current).2f) must match the " +"previously used monthly hours (%(previous).2f) in this project." +msgstr "" +"Las horas mensuales totales para los perfiles (%(current).2f) deben " +"coincidir con las usadas anteriormente en este proyecto (%(previous).2f)" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_month_line_view_form +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Timesheets" +msgstr "Partes de Horas" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__total_amount +#: model:ir.model.fields,field_description:project_fte.field_project_project__total_amount +msgid "Total Amount" +msgstr "Total" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__fte_hours +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__fte_hours +msgid "Total FTE Hours" +msgstr "Horas FTE Totales" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__fte_hours +msgid "" +"Total FTE hours to allocate for the project.\n" +" Hours are initially based on the allocated_hours of the project's tasks,\n" +" and can be manually adjusted afterwards." +msgstr "" +"Total de horas FTE a asignar al proyecto.\n" +" Las horas se basan inicialmente en las horas asignadas de las tareas del proyecto, y pueden ajustarse manualmente posteriormente." + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_month_line_view_tree +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Total Hours" +msgstr "Horas Totales" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_mass_generator__total_raw_amount +#: model:ir.model.fields,field_description:project_fte.field_project_project__total_raw_amount +msgid "Total Raw Amount" +msgstr "Total sin descuento" + +#. module: project_fte +#: model_terms:ir.ui.view,arch_db:project_fte.project_fte_mass_generator_form_view +#: model_terms:ir.ui.view,arch_db:project_fte.project_project_view_form_inherit_fte +msgid "Total Summary" +msgstr "Resumen total" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__total_amount +#: model:ir.model.fields,help:project_fte.field_project_project__total_amount +msgid "Total amount" +msgstr "Total" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__month_amount +msgid "Total amount per month." +msgstr "Total por mes" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__fte_hours +msgid "Total contracted hours for this month." +msgstr "Horas totales contratadas este mes." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator_profile__profile_price_amount +msgid "Total cost for this profile in the month." +msgstr "Coste total del perfil para este mes" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__discount_amount +#: model:ir.model.fields,help:project_fte.field_project_project__discount_amount +msgid "Total discount amount applied to the total." +msgstr "Descuento total" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__month_discount_amount +msgid "Total discount amount per month." +msgstr "Descuento total por mes" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__executed_hours +msgid "Total hours logged in timesheets for this month." +msgstr "Total de horas registradas en las hojas de tiempo durante este mes." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_project__monthly_discount_amount +msgid "Total monthly discount amount" +msgstr "Descuento total por mes" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "Total monthly hours for profiles must be greater than zero." +msgstr "El total de horas mensual de los perfiles debe ser mayor a 0." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_project__monthly_amount +msgid "Total montlhy amount" +msgstr "Monto total por mes" + +#. module: project_fte +#. odoo-python +#: code:addons/project_fte/wizard/project_fte_mass_generator.py:0 +#, python-format +msgid "Total profile hours must be greater than zero." +msgstr "El total de horas de los perfiles debe ser mayor a 0." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__total_raw_amount +#: model:ir.model.fields,help:project_fte.field_project_project__total_raw_amount +msgid "Total raw amount before applying the discount." +msgstr "Importe bruto total antes de aplicar el descuento." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_mass_generator__month_raw_amount +msgid "Total raw amount per month before discount." +msgstr "Importe bruto mensual total antes del descuento." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_project__monthly_raw_amount +msgid "Total raw monthly amount before discount." +msgstr "Importe bruto mensual antes del descuento." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "Tipo de actividad de excepción en el registro." + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_project__previous_monthly_hours +msgid "" +"Used to validate that new profile distributions match previous monthly " +"allocations." +msgstr "" +"Usado para validar que las nuevas distribuciones del perfil son iguales a " +"las introducidas anteriormente." + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: project_fte +#: model:ir.model.fields,help:project_fte.field_project_fte_month_line__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: project_fte +#: model:ir.model,name:project_fte.model_project_fte_mass_generator +msgid "Wizard to Mass Generate Project FTE Lines" +msgstr "Wizard para generar Líneas FTE masivamente" + +#. module: project_fte +#: model:ir.model.fields,field_description:project_fte.field_project_fte_month_line__year +msgid "Year" +msgstr "Año" + +#. module: project_fte +#: model:mail.template,subject:project_fte.mail_template_fte_execution_overload +msgid "⚠ Excess FTE Execution: {{object.project_id.name}}" +msgstr "⚠ Ejecución FTE Excesiva: {{object.project_id.name}}" + +#. module: project_fte +#: model:mail.template,subject:project_fte.mail_template_fte_execution_warning +msgid "⚠ FTE Execution Alert: {{object.project_id.name}}" +msgstr "⚠ Alerta de Ejecución FTE: {{object.project_id.name}}" diff --git a/project_fte/models/__init__.py b/project_fte/models/__init__.py new file mode 100644 index 0000000000..a07a3ae46d --- /dev/null +++ b/project_fte/models/__init__.py @@ -0,0 +1,6 @@ +from . import project_project +from . import project_fte_month_line +from . import project_fte_profile_distribution +from . import project_role +from . import project_milestone +from . import account_analytic_line diff --git a/project_fte/models/account_analytic_line.py b/project_fte/models/account_analytic_line.py new file mode 100644 index 0000000000..29ace028bc --- /dev/null +++ b/project_fte/models/account_analytic_line.py @@ -0,0 +1,53 @@ +from odoo import api, models + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + res._update_fte_month_lines() + return res + + def write(self, vals): + res = super().write(vals) + self._update_fte_month_lines() + return res + + def unlink(self): + for line in self: + if line.non_billable: + continue + fte_line = ( + self.env["project.fte.month.line"] + .sudo() + .search( + [ + ("project_id", "=", line.project_id.id), + ("month", "=", str(line.date.month)), + ("year", "=", line.date.year), + ] + ) + ) + fte_line.executed_hours -= line.unit_amount + + res = super().unlink() + return res + + def _update_fte_month_lines(self): + for line in self: + if not line.project_id or not line.date or line.non_billable: + continue + fte_line = ( + self.env["project.fte.month.line"] + .sudo() + .search( + [ + ("project_id", "=", line.project_id.id), + ("month", "=", str(line.date.month)), + ("year", "=", line.date.year), + ] + ) + ) + fte_line._compute_executed_hours() diff --git a/project_fte/models/project_fte_month_line.py b/project_fte/models/project_fte_month_line.py new file mode 100644 index 0000000000..9078e5b538 --- /dev/null +++ b/project_fte/models/project_fte_month_line.py @@ -0,0 +1,232 @@ +# Copyright 2025 APSL Nagarro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import date, timedelta + +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models + + +class FteMonthLine(models.Model): + _name = "project.fte.month.line" + _description = "Project FTE Month Line" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "year desc, month desc" + + project_id = fields.Many2one( + comodel_name="project.project", + string="Project", + required=True, + ondelete="cascade", + ) + name = fields.Char(compute="_compute_name", store=True) + month = fields.Selection( + selection=[ + ("1", "January"), + ("2", "February"), + ("3", "March"), + ("4", "April"), + ("5", "May"), + ("6", "June"), + ("7", "July"), + ("8", "August"), + ("9", "September"), + ("10", "October"), + ("11", "November"), + ("12", "December"), + ], + required=True, + ) + year = fields.Integer(required=True, default=lambda self: fields.Date.today().year) + profile_distribution_ids = fields.One2many( + comodel_name="project.fte.profile.distribution", + inverse_name="month_line_id", + string="Profile Distribution", + ) + fte_hours = fields.Float( + string="Total FTE Hours", + compute="_compute_fte_hours", + store=True, + help="Total contracted hours for this month.", + ) + + month_int = fields.Integer( + string="Month Number", + compute="_compute_month_int", + store=True, + help="Numeric value of the month for proper sorting.", + ) + + executed_hours = fields.Float( + compute="_compute_executed_hours", + store=True, + help="Total hours logged in timesheets for this month.", + ) + executed_percent = fields.Float( + compute="_compute_executed_percent", + store=True, + help="Percentage of executed hours compared to FTE hours.", + digits=(16, 2), + ) + + warning_sent = fields.Boolean(string="Execution Warning Sent", default=False) + overload_sent = fields.Boolean(string="Execution Overload Sent", default=False) + high_usage_sent = fields.Boolean(string="Execution High Usage Sent", default=False) + currency_id = fields.Many2one( + related="project_id.currency_id", string="Currency", readonly=True + ) + monthly_amount = fields.Monetary( + compute="_compute_monthly_amount", + store=True, + help="Total monetary amount for this month's FTE.", + currency_field="currency_id", + ) + + @api.depends("month") + def _compute_month_int(self): + for line in self: + line.month_int = int(line.month) if line.month else 0 + + _sql_constraints = [ + ( + "project_month_year_uniq", + "unique(project_id, month, year)", + "A line for this month and year already exists for this project.", + ) + ] + + @api.depends("month", "year") + def _compute_name(self): + for line in self: + if line.month and line.year: + month_str = dict(self._fields["month"].selection).get(line.month) + line.name = f"{month_str} {line.year}" + else: + line.name = _("New") + + @api.depends("profile_distribution_ids.profile_hours") + def _compute_fte_hours(self): + for line in self: + line.fte_hours = sum(line.profile_distribution_ids.mapped("profile_hours")) + + def unlink(self): + affected_projects = self.mapped("project_id") + res = super().unlink() + + for project in affected_projects: + remaining = self.search_count([("project_id", "=", project.id)]) + if remaining == 0: + project.previous_monthly_hours = False + + return res + + @api.depends("project_id", "month", "year") + def _compute_executed_hours(self): + AnalyticLine = self.env["account.analytic.line"] + + for line in self: + line.executed_hours = 0.0 + if not line.project_id or not line.month or not line.year: + continue + + start_date = date(int(line.year), int(line.month), 1) + end_date = (start_date + relativedelta(months=1)) - timedelta(days=1) + + domain = [ + ("project_id", "=", line.project_id.id), + ("date", ">=", start_date), + ("date", "<=", end_date), + ("non_billable", "=", False), + ] + + hours = AnalyticLine.search(domain).mapped("unit_amount") + line.executed_hours = sum(hours) + + @api.depends("executed_hours", "fte_hours") + def _compute_executed_percent(self): + for line in self: + if line.fte_hours: + line.executed_percent = (line.executed_hours / line.fte_hours) * 100 + else: + line.executed_percent = 0.0 + + @api.depends("profile_distribution_ids", "project_id.discount") + def _compute_monthly_amount(self): + for line in self: + total = 0.0 + for profile in line.profile_distribution_ids: + total += profile.profile_hours * profile.profile_price_hour + + if line.project_id.discount: + total -= total * line.project_id.discount + + line.monthly_amount = total + + @api.model + def _cron_check_fte_execution(self): + today = date.today() + current_month = today.month + current_year = today.year + is_mid_month = today.day >= 15 + + lines = self.search( + [ + ("month", "=", str(current_month)), + ("year", "=", current_year), + ] + ) + + template_low = self.env.ref("project_fte.mail_template_fte_execution_warning") + template_high = self.env.ref("project_fte.mail_template_fte_execution_overload") + + for line in lines: + percent = line.executed_percent or 0.0 + project = line.project_id + user = project.user_id + + if not user or not user.email: + continue + + # Warning when execution is below 50% at mid-month + if is_mid_month and percent < 50 and not line.warning_sent: + template_low.send_mail(line.id, force_send=True) + line.warning_sent = True + continue + + # Warning when execution exceeds 100% + if percent > 100 and not line.overload_sent: + template_high.send_mail(line.id, force_send=True) + line.overload_sent = True + continue + + # Warning when execution is above 75% at mid-month + if ( + is_mid_month + and percent > 75 + and not line.high_usage_sent + and not line.overload_sent + ): + template_high.send_mail(line.id, force_send=True) + line.high_usage_sent = True + + def action_view_timesheets(self): + self.ensure_one() + start_date = date(int(self.year), int(self.month), 1) + end_date = (start_date + relativedelta(months=1)) - timedelta(days=1) + + action = self.env["ir.actions.act_window"]._for_xml_id( + "hr_timesheet.timesheet_action_all" + ) + action.update( + { + "domain": [ + ("project_id", "=", self.project_id.id), + ("date", ">=", start_date), + ("date", "<=", end_date), + ("non_billable", "=", False), + ], + "context": {"default_project_id": self.project_id.id}, + } + ) + return action diff --git a/project_fte/models/project_fte_profile_distribution.py b/project_fte/models/project_fte_profile_distribution.py new file mode 100644 index 0000000000..40fbf36886 --- /dev/null +++ b/project_fte/models/project_fte_profile_distribution.py @@ -0,0 +1,44 @@ +# Copyright 2025 APSL Nagarro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class FteProfileDistribution(models.Model): + _name = "project.fte.profile.distribution" + _description = "Project FTE Profile Distribution" + + month_line_id = fields.Many2one( + comodel_name="project.fte.month.line", + string="Month Line", + required=True, + ondelete="cascade", + ) + role_id = fields.Many2one( + comodel_name="project.role", + string="Profile/Role", + required=True, + ) + profile_hours = fields.Float() + profile_hours_percentage = fields.Float( + string="Percentage", + compute="_compute_profile_hours_percentage", + store=True, + help="Percentage of this profile's hours over the total for the month.", + ) + profile_price_hour = fields.Float( + string="Price per Hour", + store=True, + help="Price per hour for this profile.", + ) + + @api.depends("profile_hours", "month_line_id.fte_hours") + def _compute_profile_hours_percentage(self): + for dist in self: + total_hours = dist.month_line_id.fte_hours + if total_hours > 0: + dist.profile_hours_percentage = ( + (dist.profile_hours * 100) / total_hours + ) / 100 + else: + dist.profile_hours_percentage = 0.0 diff --git a/project_fte/models/project_milestone.py b/project_fte/models/project_milestone.py new file mode 100644 index 0000000000..f01979f265 --- /dev/null +++ b/project_fte/models/project_milestone.py @@ -0,0 +1,9 @@ +from odoo import fields, models + + +class ProjectMilestone(models.Model): + _inherit = "project.milestone" + + project_role_id = fields.Many2one( + "project.role", + ) diff --git a/project_fte/models/project_project.py b/project_fte/models/project_project.py new file mode 100644 index 0000000000..c67fd7b517 --- /dev/null +++ b/project_fte/models/project_project.py @@ -0,0 +1,326 @@ +# Copyright 2025 APSL Nagarro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import api, fields, models + + +class Project(models.Model): + _inherit = "project.project" + + fte_month_line_ids = fields.One2many( + comodel_name="project.fte.month.line", + inverse_name="project_id", + string="FTE Month Lines", + ) + + previous_monthly_hours = fields.Float( + help="Used to validate that new profile distributions " + "match previous monthly allocations.", + ) + + discount = fields.Float( + string="Discount (%)", + default=0.0, + help="Percentage discount applied to the total amount.", + ) + currency_id = fields.Many2one( + comodel_name="res.currency", + related="company_id.currency_id", + store=True, + ) + + total_raw_amount = fields.Monetary( + compute="_compute_total_amount", + store=True, + help="Total raw amount before applying the discount.", + currency_field="currency_id", + ) + total_amount = fields.Monetary( + compute="_compute_total_amount", + store=True, + help="Total amount", + currency_field="currency_id", + ) + discount_amount = fields.Monetary( + compute="_compute_total_amount", + store=True, + help="Total discount amount applied to the total.", + currency_field="currency_id", + ) + + monthly_raw_amount = fields.Monetary( + compute="_compute_monthly_amount", + store=True, + help="Total raw monthly amount before discount.", + currency_field="currency_id", + ) + monthly_amount = fields.Monetary( + compute="_compute_monthly_amount", + store=True, + help="Total montlhy amount", + currency_field="currency_id", + ) + monthly_discount_amount = fields.Monetary( + compute="_compute_monthly_amount", + store=True, + help="Total monthly discount amount", + currency_field="currency_id", + ) + + allocated_hours = fields.Float( + compute="_compute_allocated_hours", + store=True, + readonly=False, + ) + + monthly_html_table = fields.Html( + string="Monthly FTE Breakdown", + compute="_compute_monthly_html_table", + sanitize=False, + store=True, + readonly=True, + ) + + fte_months = fields.Float( + string="FTE Months", + compute="_compute_fte_months", + store=True, + help="Number of months for which FTE lines will be generated.", + default=0.0, + ) + + is_fte_closed = fields.Boolean(default=False) + + @api.depends( + "fte_month_line_ids", "fte_month_line_ids.profile_distribution_ids", "discount" + ) + def _compute_monthly_html_table(self): + for project in self: + lines = project.fte_month_line_ids + if not lines: + project.monthly_html_table = "" + continue + + line = sorted(lines, key=lambda line: (int(line.year), int(line.month)))[0] + currency = project.currency_id.symbol or "€" + monthly_hours = project.previous_monthly_hours or 0.0 + + row_lines = [] + month_total = 0.0 + + ordered_dists = sorted( + line.profile_distribution_ids, + key=lambda d: d.profile_hours_percentage, + reverse=True, + ) + + for dist in ordered_dists: + hours = monthly_hours * dist.profile_hours_percentage + amount = hours * dist.profile_price_hour + month_total += amount + row_lines.append( + f""" + + {dist.role_id.name} + {dist.profile_price_hour:.2f} {currency} + {hours:.2f} + {dist.profile_hours_percentage * 100:.2f}% + {amount:,.2f} {currency} + + """ + ) + + discount_amount = month_total * (project.discount or 0.0) + final = month_total - discount_amount + + header = """ + + Profile + Hourly Rate + Monthly Hours + Distribution (%) + Amount + + """ + + row_lines.append( + f""" + + + Total + + + {monthly_hours:.2f} + + + 100% + + {month_total:,.2f} {currency} + + + """ + ) + if project.discount > 0.0: + row_lines.append( + f""" + + + Discount + + + {project.discount * 100:.0f}% + + + {discount_amount:,.2f} {currency} + + + """ + ) + row_lines.append( + f""" + + + + Cost + + + + {final:,.2f} {currency} + + + + + + Months + + + + {project.fte_months} + + + """ + ) + + project.monthly_html_table = f""" + + {header} + {''.join(row_lines)} +
+ """ + + @api.depends("fte_month_line_ids", "discount") + def _compute_total_amount(self): + for project in self: + total_amount = 0.0 + discount_amount = 0.0 + for month_line in project.fte_month_line_ids: + for dist in month_line.profile_distribution_ids: + total_amount += dist.profile_hours * dist.profile_price_hour + project.total_raw_amount = total_amount + discount_amount = project.discount * total_amount + project.total_amount = total_amount - discount_amount + project.discount_amount = discount_amount + + @api.depends("fte_month_line_ids", "discount") + def _compute_monthly_amount(self): + for project in self: + monthly_raw_amount = 0.0 + monthly_amount = 0.0 + monthly_discount_amount = 0.0 + + if project.fte_month_line_ids and project.previous_monthly_hours: + first_line = project.fte_month_line_ids.sorted( + key=lambda line: (int(line.year), int(line.month)) + )[0] + + monthly_hours = project.previous_monthly_hours + + for line in first_line.profile_distribution_ids: + hours = monthly_hours * line.profile_hours_percentage + monthly_raw_amount += hours * line.profile_price_hour + + monthly_discount_amount = monthly_raw_amount * project.discount + monthly_amount = monthly_raw_amount - monthly_discount_amount + project.monthly_raw_amount = monthly_raw_amount + project.monthly_amount = monthly_amount + project.monthly_discount_amount = monthly_discount_amount + + @api.depends( + "fte_month_line_ids.profile_distribution_ids.profile_hours", + "fte_month_line_ids", + ) + def _compute_allocated_hours(self): + for project in self: + allocated_hours = sum( + dist.profile_hours + for month_line in project.fte_month_line_ids + for dist in month_line.profile_distribution_ids + ) + project.allocated_hours = allocated_hours + + @api.depends( + "fte_month_line_ids", "fte_month_line_ids.fte_hours", "previous_monthly_hours" + ) + def _compute_fte_months(self): + for project in self: + total_hours = sum(project.fte_month_line_ids.mapped("fte_hours")) + if project.previous_monthly_hours > 0: + project.fte_months = round( + total_hours / project.previous_monthly_hours, 2 + ) + else: + project.fte_months = 0.0 + + def action_copy_last_fte_line(self): + self.ensure_one() + sorted_fte_lines = self.fte_month_line_ids.sorted( + key=lambda line: (int(line.year), int(line.month)), reverse=True + ) + last_fte_line = sorted_fte_lines[0] + current_month = int(last_fte_line.month) + next_month = current_month + 1 if current_month < 12 else 1 + year = last_fte_line.year if next_month > 1 else last_fte_line.year + 1 + + new_line = last_fte_line.copy( + default={ + "month": str(next_month), + "project_id": last_fte_line.project_id.id, + "fte_hours": last_fte_line.fte_hours, + "year": year, + } + ) + + new_distributions = last_fte_line.profile_distribution_ids.mapped( + lambda dist: { + "role_id": dist.role_id.id, + "profile_hours": dist.profile_hours, + "profile_price_hour": dist.profile_price_hour, + } + ) + new_line.profile_distribution_ids = [(0, 0, vals) for vals in new_distributions] + + return True + + def action_delete_fte(self): + self.ensure_one() + if not self.fte_month_line_ids: + return True + + # Eliminate all FTE month lines and their distributions + for line in self.fte_month_line_ids: + line.profile_distribution_ids.unlink() + self.fte_month_line_ids.unlink() + + return True + + def action_close_fte(self): + self.ensure_one() + self.is_fte_closed = True + return True + + def action_reopen_fte(self): + self.ensure_one() + self.is_fte_closed = False + return True diff --git a/project_fte/models/project_role.py b/project_fte/models/project_role.py new file mode 100644 index 0000000000..44a0fd0dea --- /dev/null +++ b/project_fte/models/project_role.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class ProjectRole(models.Model): + _inherit = "project.role" + + price_hour = fields.Float( + string="Price per Hour", + help="Price per hour for this role.", + digits=(16, 2), + ) diff --git a/project_fte/pyproject.toml b/project_fte/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/project_fte/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/project_fte/readme/CONTRIBUTORS.md b/project_fte/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..0f23521f45 --- /dev/null +++ b/project_fte/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +* `APSL-Nagarro `_: + * Miquel Alzanillas + * Miquel Pascual + * Bernat Obrador diff --git a/project_fte/readme/DESCRIPTION.md b/project_fte/readme/DESCRIPTION.md new file mode 100644 index 0000000000..32ba7c7b2b --- /dev/null +++ b/project_fte/readme/DESCRIPTION.md @@ -0,0 +1 @@ +With this module you can manage FTE (Full-Time Equivalent) contracts and evolution in projects. \ No newline at end of file diff --git a/project_fte/readme/USAGE.md b/project_fte/readme/USAGE.md new file mode 100644 index 0000000000..fac4835874 --- /dev/null +++ b/project_fte/readme/USAGE.md @@ -0,0 +1,41 @@ +1. **Go to the desired project**, click the three dots (⋯) in the top-right corner, and select **Settings**. +2. A new section named **FTE** will appear in the project form. +3. Click the **Generate FTE Lines** button to open the wizard. + +### 🔧 In the wizard: + +- Select the **Start Date**. This date will be stored on the project. +- **total FTE hours** will be calculated from the total of the allocated_hours of the tasks. +- Fill in the **Monthly Hours** manually. These represent the typical number of hours to distribute per month, and are used to calculate the duration. +- Define the **Profile Distribution** by selecting the roles and specifying the number of hours per role. + +> 💡 If your roles have a **Price per Hour** defined (on the role itself), the wizard will use it to compute the cost of each role’s hours. + +- The wizard will automatically compute: + - The **percentage** of each role's hours, + - The **Monthly Amount** and **Total Amount**, + - The **End Date**, based on the total hours and monthly distribution. + +--- + +### 🎯 Autofill from Milestones + +Instead of filling the profile distribution manually, you can click **Load from Milestones**: + +- This will gather all project tasks linked to milestones, +- And group their allocated hours by the **role assigned to each milestone**, +- Creating a profile distribution automatically. + +This is useful when project planning has already been done using milestones. + +--- + +### 📆 Generating the Lines + +Once all fields are filled: + +1. Click **Generate**. +2. FTE lines will be created month by month from the selected Start Date to the computed End Date. +3. The hours and costs are distributed according to the profile distribution. + +--- diff --git a/project_fte/security/ir.model.access.csv b/project_fte/security/ir.model.access.csv new file mode 100644 index 0000000000..eb74cf63c0 --- /dev/null +++ b/project_fte/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_project_fte_month_line_manager,project.fte.month.line.manager,model_project_fte_month_line,project.group_project_manager,1,1,1,1 +access_project_fte_profile_distribution_manager,project.fte.profile.distribution.manager,model_project_fte_profile_distribution,project.group_project_manager,1,1,1,1 +access_project_fte_mass_generator_manager,project.fte.mass.generator.manager,model_project_fte_mass_generator,project.group_project_manager,1,1,1,1 +access_project_fte_mass_generator_profile_manager,project.fte.mass.generator.profile.manager,model_project_fte_mass_generator_profile,project.group_project_manager,1,1,1,1 +access_project_fte_month_line_user,project.fte.month.line user,model_project_fte_month_line,project.group_project_user,1,0,0,0 +access_project_fte_profile_distribution_user,project.fte.profile.distribution user,model_project_fte_profile_distribution,project.group_project_user,1,0,0,0 diff --git a/project_fte/static/description/icon.png b/project_fte/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/project_fte/static/description/icon.png differ diff --git a/project_fte/static/description/index.html b/project_fte/static/description/index.html new file mode 100644 index 0000000000..0f3f323f94 --- /dev/null +++ b/project_fte/static/description/index.html @@ -0,0 +1,444 @@ + + + + + +Project FTE + + + +
+

Project FTE

+ + +

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

+

With this module you can manage FTE (Full-Time Equivalent) contracts and +evolution in projects.

+

Table of contents

+ +
+

Usage

+

Go to the project you want, click the three dots and select Settings. +You will see that you have a new page named FTE. Click on the Generate +FTE Lines button. Select the Start Date you wish (it will be setted in +the project) and the total FTE hours contracted. Select the profile +distribution you want, it will autocompute Monthly Hours and End Date. +Generate the lines. You can add now more lines if you wish, following +the same steps. You will see that now Start Date is the previous end +date. Check the Overwrite Existing Lines button. It will compute the new +lines based on the previous end date, in order to create them starting +from that date.

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • APSL Nagarro
  • +
+
+
+

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.

+

Current maintainers:

+

miquelalzanillas mpascuall

+

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_fte/tests/__init__.py b/project_fte/tests/__init__.py new file mode 100644 index 0000000000..2832ac2dcd --- /dev/null +++ b/project_fte/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 APSL Nagarro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_project_fte diff --git a/project_fte/tests/test_project_fte.py b/project_fte/tests/test_project_fte.py new file mode 100644 index 0000000000..069f0bebae --- /dev/null +++ b/project_fte/tests/test_project_fte.py @@ -0,0 +1,506 @@ +# Copyright 2025 APSL Nagarro +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time +from psycopg2 import IntegrityError + +from odoo import fields +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestProjectFte(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + ) + ) + cls.user = cls.env.ref("base.user_admin") + cls.Project = cls.env["project.project"] + cls.Role = cls.env["project.role"] + cls.FteLine = cls.env["project.fte.month.line"] + cls.FteDist = cls.env["project.fte.profile.distribution"] + cls.Wizard = cls.env["project.fte.mass.generator"] + cls.Task = cls.env["project.task"] + + cls.project = cls.Project.create({"name": "Test FTE Project"}) + cls.role_dev = cls.Role.create({"name": "Developer"}) + cls.role_pm = cls.Role.create({"name": "Project Manager"}) + + cls.time_type_billable = cls.env["project.time.type"].create( + { + "name": "Billable", + "non_billable": False, + } + ) + cls.time_type_non_billable = cls.env["project.time.type"].create( + { + "name": "Billable", + "non_billable": True, + } + ) + + def test_01_creation_and_name_compute(self): + fte_line = self.FteLine.create( + { + "project_id": self.project.id, + "month": "1", + "year": 2025, + } + ) + self.assertTrue(fte_line, "FTE Line should be created.") + self.assertEqual( + fte_line.name, + "January 2025", + "The name should be computed correctly.", + ) + self.assertEqual(fte_line.fte_hours, 0.0, "Initial FTE hours should be zero.") + + def test_02_fte_hours_computation(self): + fte_line = self.FteLine.create( + { + "project_id": self.project.id, + "month": "2", + "year": 2025, + "profile_distribution_ids": [ + (0, 0, {"role_id": self.role_dev.id, "profile_hours": 120}), + (0, 0, {"role_id": self.role_pm.id, "profile_hours": 40}), + ], + } + ) + self.assertEqual( + fte_line.fte_hours, 160.0, "Total FTE hours should be the sum of its lines." + ) + fte_line.profile_distribution_ids[0].profile_hours = 100 + self.assertEqual( + fte_line.fte_hours, 140.0, "Total should be updated after a line change." + ) + + fte_line.profile_distribution_ids = [ + (0, 0, {"role_id": self.role_dev.id, "profile_hours": 20}) + ] + self.assertEqual( + fte_line.fte_hours, + 160.0, + "Total should be updated after adding a new line.", + ) + + def test_03_percentage_computation(self): + fte_line = self.FteLine.create( + { + "project_id": self.project.id, + "month": "3", + "year": 2025, + } + ) + dist_dev = self.FteDist.create( + { + "month_line_id": fte_line.id, + "role_id": self.role_dev.id, + "profile_hours": 120, + } + ) + dist_pm = self.FteDist.create( + { + "month_line_id": fte_line.id, + "role_id": self.role_pm.id, + "profile_hours": 40, + } + ) + + self.assertAlmostEqual(dist_dev.profile_hours_percentage, 0.75) + self.assertAlmostEqual(dist_pm.profile_hours_percentage, 0.25) + + dist_dev.profile_hours = 0 + dist_pm.profile_hours = 0 + self.assertAlmostEqual(dist_dev.profile_hours_percentage, 0.0) + self.assertAlmostEqual(dist_pm.profile_hours_percentage, 0.0) + + @mute_logger("odoo.sql_db") + def test_04_sql_constraint(self): + self.FteLine.create( + { + "project_id": self.project.id, + "month": "4", + "year": 2025, + } + ) + with self.assertRaises(IntegrityError): + self.FteLine.create( + { + "project_id": self.project.id, + "month": "4", + "year": 2025, + } + ) + + def test_05_ondelete_cascade(self): + project_to_delete = self.Project.create({"name": "To Be Deleted"}) + fte_line = self.FteLine.create( + { + "project_id": project_to_delete.id, + "month": "5", + "year": 2025, + } + ) + dist_line = self.FteDist.create( + { + "month_line_id": fte_line.id, + "role_id": self.role_dev.id, + "profile_hours": 10, + } + ) + fte_line_id = fte_line.id + dist_line_id = dist_line.id + fte_line.unlink() + self.assertFalse( + self.FteDist.browse(dist_line_id).exists(), + "Distribution line should be deleted when its " + "parent month line is deleted.", + ) + self.assertFalse( + self.FteLine.browse(fte_line_id).exists(), + "Month line should be deleted.", + ) + + fte_line = self.FteLine.create( + { + "project_id": project_to_delete.id, + "month": "6", + "year": 2025, + } + ) + fte_line_id = fte_line.id + project_to_delete.unlink() + self.assertFalse( + self.FteLine.browse(fte_line_id).exists(), + "FTE month line should be deleted when its project is deleted.", + ) + + def test_06_distribution_without_discount(self): + role = self.role_dev + role.price_hour = 50 + milestone = self.env["project.milestone"].create( + { + "name": "Milestone Dev", + "project_id": self.project.id, + "project_role_id": role.id, + } + ) + self.Task.create( + { + "name": "Task 1", + "project_id": self.project.id, + "allocated_hours": 40, + "milestone_id": milestone.id, + } + ) + self.Task.create( + { + "name": "Task 2", + "project_id": self.project.id, + "allocated_hours": 60, + "milestone_id": milestone.id, + } + ) + + self.project._compute_allocated_hours() + + wizard = self.Wizard.create( + { + "project_id": self.project.id, + "date_from": fields.Date.to_date("2025-02-01"), + "fte_hours": 100, + } + ) + + result = wizard.compute_profile_distribution_from_milestones() + new_wizard = self.Wizard.browse(result["res_id"]) + line = new_wizard.profile_distribution_ids[0] + + self.assertEqual(line.role_id, role) + self.assertEqual(line.profile_hours, 100) + self.assertEqual(line.profile_price_hour, 50) + self.assertEqual(line.profile_price_amount, 5000) + + def test_07_load_from_milestones(self): + milestone_role_1 = self.Role.create( + { + "name": "Analyst", + "price_hour": 80, + } + ) + milestone_role_2 = self.Role.create( + { + "name": "Senior Analyst", + "price_hour": 100, + } + ) + + milestone_1 = self.env["project.milestone"].create( + { + "name": "Milestone 1", + "project_id": self.project.id, + "project_role_id": milestone_role_1.id, + } + ) + milestone_2 = self.env["project.milestone"].create( + { + "name": "Milestone 2", + "project_id": self.project.id, + "project_role_id": milestone_role_2.id, + } + ) + + self.Task.create( + { + "name": "Task A", + "project_id": self.project.id, + "allocated_hours": 100, + "milestone_id": milestone_1.id, + } + ) + self.Task.create( + { + "name": "Task A_1", + "project_id": self.project.id, + "allocated_hours": 60, + "milestone_id": milestone_1.id, + } + ) + self.Task.create( + { + "name": "Task B", + "project_id": self.project.id, + "allocated_hours": 50, + "milestone_id": milestone_2.id, + } + ) + self.Task.create( + { + "name": "Task B_1", + "project_id": self.project.id, + "allocated_hours": 60, + "milestone_id": milestone_2.id, + } + ) + + self.project._compute_allocated_hours() + + wizard = self.Wizard.create( + { + "project_id": self.project.id, + "date_from": fields.Date.to_date("2025-01-01"), + "fte_hours": 270, + } + ) + + result = wizard.compute_profile_distribution_from_milestones() + new_wizard = self.Wizard.browse(result["res_id"]) + + distribution_lines = new_wizard.profile_distribution_ids + self.assertEqual(len(distribution_lines), 2) + + for line in distribution_lines: + if line.role_id == milestone_role_1: + self.assertEqual(line.profile_hours, 160) + self.assertEqual(line.profile_price_hour, 80) + self.assertEqual(line.profile_price_amount, 12800) + elif line.role_id == milestone_role_2: + self.assertEqual(line.profile_hours, 110) + self.assertEqual(line.profile_price_hour, 100) + self.assertEqual(line.profile_price_amount, 11000) + + new_wizard.monthly_hours = 135 + new_wizard.discount = 0.1 + + new_wizard._compute_date_to() + new_wizard._compute_total_amount() + new_wizard._compute_month_amount() + + self.assertEqual( + new_wizard.date_to.month, + fields.Date.to_date("2025-03-31").month, + "should create 2 months", + ) + self.assertAlmostEqual(new_wizard.fte_months, 2.0) + + self.assertEqual(new_wizard.total_raw_amount, 23800) + self.assertEqual(new_wizard.total_amount, 23800 * 0.9) + self.assertEqual(new_wizard.discount_amount, 23800 * 0.1) + + month_raw_total = new_wizard.total_raw_amount / (new_wizard.date_to.month - 1) + + self.assertAlmostEqual(new_wizard.month_raw_amount, month_raw_total) + self.assertAlmostEqual(new_wizard.month_amount, month_raw_total * 0.9) + self.assertAlmostEqual(new_wizard.month_discount_amount, month_raw_total * 0.1) + + self.assertEqual(result["res_model"], "project.fte.mass.generator") + self.assertEqual(result["res_id"], wizard.id) + self.assertEqual(result["view_mode"], "form") + + new_wizard.action_generate_lines() + + fte_lines = self.FteLine.search([("project_id", "=", self.project.id)]) + self.assertTrue(fte_lines) + + self.assertAlmostEqual(self.project.total_raw_amount, wizard.total_raw_amount) + self.assertAlmostEqual(self.project.discount_amount, wizard.discount_amount) + self.assertAlmostEqual(self.project.total_amount, wizard.total_amount) + self.assertAlmostEqual(self.project.fte_months, 2.0) + + self.assertAlmostEqual(self.project.monthly_raw_amount, wizard.month_raw_amount) + self.assertAlmostEqual( + self.project.monthly_discount_amount, wizard.month_discount_amount + ) + self.assertAlmostEqual(self.project.monthly_amount, wizard.month_amount) + + self.assertEqual(len(self.project.fte_month_line_ids), 2) + self.project.action_copy_last_fte_line() + self.assertEqual(len(self.project.fte_month_line_ids), 3) + + def test_08_executed_hours_and_percent(self): + AccountAnalyticLine = self.env["account.analytic.line"] + + fte_line = self.FteLine.create( + { + "fte_hours": 100, + "project_id": self.project.id, + "month": "7", + "year": 2025, + "profile_distribution_ids": [ + (0, 0, {"role_id": self.role_dev.id, "profile_hours": 100}), + ], + } + ) + + self.assertEqual(fte_line.executed_hours, 0.0) + self.assertEqual(fte_line.executed_percent, 0.0) + + line = AccountAnalyticLine.create( + { + "name": "Timesheet A", + "project_id": self.project.id, + "unit_amount": 40, + "date": fields.Date.to_date("2025-07-15"), + "user_id": self.user.id, + "time_type_id": self.time_type_billable.id, + } + ) + + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + self.assertEqual(fte_line.executed_hours, 40) + self.assertEqual(fte_line.executed_percent, 40) + + AccountAnalyticLine.create( + { + "name": "Timesheet B", + "project_id": self.project.id, + "unit_amount": 99, + "date": fields.Date.to_date("2025-08-01"), + "user_id": self.user.id, + "time_type_id": self.time_type_billable.id, + } + ) + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + self.assertEqual(fte_line.executed_hours, 40) + self.assertEqual(fte_line.executed_percent, 40) + + line.write({"unit_amount": 60}) + + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + self.assertEqual(fte_line.executed_hours, 60) + self.assertEqual(fte_line.executed_percent, 60) + + line.unlink() + self.assertEqual(fte_line.executed_hours, 0.0) + self.assertEqual(fte_line.executed_percent, 0.0) + + AccountAnalyticLine.create( + { + "name": "Timesheet Non Billable", + "project_id": self.project.id, + "unit_amount": 10, + "date": fields.Date.to_date("2025-08-01"), + "user_id": self.user.id, + "time_type_id": self.time_type_non_billable.id, + } + ) + + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + self.assertEqual(fte_line.executed_hours, 0.0) + + @freeze_time("2025-07-16") + def test_09_test_cron_warning_state(self): + AccountAnalyticLine = self.env["account.analytic.line"] + fte_line = self.FteLine.create( + { + "fte_hours": 100, + "project_id": self.project.id, + "month": "7", + "year": 2025, + "profile_distribution_ids": [ + (0, 0, {"role_id": self.role_dev.id, "profile_hours": 100}), + ], + } + ) + + self.assertFalse( + not fte_line.high_usage_sent + and not fte_line.warning_sent + and fte_line.overload_sent, + ) + + AccountAnalyticLine.create( + { + "name": "Timesheet A", + "project_id": self.project.id, + "unit_amount": 20, + "date": fields.Date.to_date("2025-07-16"), + "user_id": self.user.id, + "time_type_id": self.time_type_billable.id, + } + ) + + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + fte_line._cron_check_fte_execution() + self.assertTrue(fte_line.warning_sent) + AccountAnalyticLine.create( + { + "name": "Timesheet B", + "project_id": self.project.id, + "unit_amount": 60, + "date": fields.Date.to_date("2025-07-16"), + "user_id": self.user.id, + "time_type_id": self.time_type_billable.id, + } + ) + + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + fte_line._cron_check_fte_execution() + self.assertTrue(fte_line.high_usage_sent) + + AccountAnalyticLine.create( + { + "name": "Timesheet C", + "project_id": self.project.id, + "unit_amount": 50, + "date": fields.Date.to_date("2025-07-16"), + "user_id": self.user.id, + "time_type_id": self.time_type_billable.id, + } + ) + fte_line._compute_executed_hours() + fte_line._compute_executed_percent() + fte_line._cron_check_fte_execution() + self.assertTrue(fte_line.overload_sent) diff --git a/project_fte/views/project_fte_month_line_views.xml b/project_fte/views/project_fte_month_line_views.xml new file mode 100644 index 0000000000..6059dbf4c4 --- /dev/null +++ b/project_fte/views/project_fte_month_line_views.xml @@ -0,0 +1,102 @@ + + + + + project.fte.month.line.view.tree + project.fte.month.line + + + + + + + + + + + + + + + project.fte.month.line.view.form + project.fte.month.line + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + FTE Month Lines + project.fte.month.line + tree,form + [('project_id', '=', active_id)] + +

+ Create a new FTE line for your project. +

+
+
+ +
diff --git a/project_fte/views/project_milestone.xml b/project_fte/views/project_milestone.xml new file mode 100644 index 0000000000..816db87fb7 --- /dev/null +++ b/project_fte/views/project_milestone.xml @@ -0,0 +1,15 @@ + + + + + project.milestone.view.tree.inherit + project.milestone + + + + + + + + + diff --git a/project_fte/views/project_project_views.xml b/project_fte/views/project_project_views.xml new file mode 100644 index 0000000000..2dcaee6ba4 --- /dev/null +++ b/project_fte/views/project_project_views.xml @@ -0,0 +1,168 @@ + + + + project.project.view.form.inherit.fte + project.project + + + +