diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 3e7f506f890..db60f494a92 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -22,6 +22,7 @@ have real delivered quantities in sales orders. 'views/sale_order_views.xml', 'views/product_views.xml', 'views/project_task_views.xml', + 'views/project_update_templates.xml', 'views/hr_timesheet_views.xml', 'views/res_config_settings_views.xml', 'views/hr_timesheet_templates.xml', diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index 3f1832b8a84..c80189a23ff 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -1685,5 +1685,23 @@ -30.0 + + Weekly review + + + on_track + + + Weekly review + + + on_hold + + + Review of the situation + + + at_risk + diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py index d86f267756a..323cc920720 100644 --- a/addons/sale_timesheet/models/__init__.py +++ b/addons/sale_timesheet/models/__init__.py @@ -6,5 +6,6 @@ from . import account_move from . import product from . import project from . import project_overview +from . import project_update from . import sale_order from . import project_sale_line_employee_map diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index ad0cf1a333b..29d071ebd78 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -6,6 +6,8 @@ from collections import defaultdict from odoo import api, fields, models, _ from odoo.osv import expression from odoo.exceptions import ValidationError, UserError +from odoo.tools import format_amount, float_round, float_is_zero +from odoo.tools.misc import formatLang # YTI PLEASE SPLIT ME @@ -251,6 +253,84 @@ class Project(models.Model): } return action_window + # ---------------------------- + # Project Updates + # ---------------------------- + + def get_panel_data(self): + panel_data = super(Project, self).get_panel_data() + return { + **panel_data, + 'profitability_items': self._get_profitability_items() + } + + def _get_profitability_items(self): + if not self.user_has_groups('project.group_project_manager'): + return {'data': []} + profitability = self.get_profitability_common() + data = [] + if self.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user'): + data += [{ + 'name': _("Timesheets"), + 'value': self.env.ref('sale_timesheet.project_profitability_timesheet_panel')._render({ + 'timesheet_unit_amount': float_round(profitability['timesheet_unit_amount'], precision_digits=2), + 'timesheet_uom': self.env.company._timesheet_uom_text(), + 'is_timesheet_uom_hour': self.env.company._is_timesheet_hour_uom(), + 'percentage_billable': formatLang(self.env, profitability['timesheet_percentage_billable'], digits=0), + }, engine='ir.qweb'), + }] + if self.allow_billable: + margin_color = False + if not float_is_zero(profitability['margin'], precision_digits=0): + margin_color = profitability['margin'] > 0 and 'green' or 'red' + data += [{ + 'name': _("Revenues"), + 'value': format_amount(self.env, profitability['revenues'], self.env.company.currency_id) + }, { + 'name': _("Costs"), + 'value': format_amount(self.env, profitability['costs'], self.env.company.currency_id) + }, { + 'name': _("Margin"), + 'color': margin_color, + 'value': format_amount(self.env, profitability['margin'], self.env.company.currency_id) + }] + return { + 'action': self.allow_billable and self.allow_timesheets and "action_view_timesheet", + 'allow_billable': self.allow_billable, + 'data': data, + } + + def get_profitability_common(self): + self.ensure_one() + profitability = self.env['project.profitability.report'].read_group( + [('project_id', '=', self.id)], + ['project_id', + 'timesheet_unit_amount', + 'amount_untaxed_to_invoice', + 'amount_untaxed_invoiced', + 'expense_amount_untaxed_to_invoice', + 'expense_amount_untaxed_invoiced', + 'other_revenues', + 'expense_cost', + 'timesheet_cost', + 'margin'], + ['project_id']) + timesheets = self.env['account.analytic.line'].read_group([('project_id', '=', self.id)], ['so_line', 'unit_amount'], ['so_line']) + timesheet_billable = timesheet_non_billable = 0.0 + for timesheet in timesheets: + if timesheet['so_line']: + timesheet_billable += timesheet['unit_amount'] + else: + timesheet_non_billable += timesheet['unit_amount'] + return { + 'costs': profitability and profitability[0]['timesheet_cost'] + profitability[0]['expense_cost'] or 0.0, + 'margin': profitability and profitability[0]['margin'] or 0.0, + 'revenues': profitability and (profitability[0]['amount_untaxed_invoiced'] + profitability[0]['amount_untaxed_to_invoice'] + + profitability[0]['expense_amount_untaxed_invoiced'] + profitability[0]['expense_amount_untaxed_to_invoice'] + + profitability[0]['other_revenues']) or 0.0, + 'timesheet_unit_amount': profitability and self._convert_project_uom_to_timesheet_encode_uom(profitability[0]['timesheet_unit_amount']) or 0.0, + 'timesheet_percentage_billable': timesheet_billable and timesheet_billable / (timesheet_billable + timesheet_non_billable) * 100 or 0.0, + } class ProjectTask(models.Model): _inherit = "project.task" diff --git a/addons/sale_timesheet/models/project_update.py b/addons/sale_timesheet/models/project_update.py new file mode 100644 index 00000000000..f32a0952412 --- /dev/null +++ b/addons/sale_timesheet/models/project_update.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.tools import float_utils, format_amount +from odoo.tools.misc import formatLang + +class ProjectUpdate(models.Model): + _inherit = 'project.update' + + @api.model + def _get_template_values(self, project): + return { + **super(ProjectUpdate, self)._get_template_values(project), + 'profitability': self._get_profitability_values(project), + } + + @api.model + def _get_profitability_values(self, project): + if not (self.user_has_groups('project.group_project_manager') and (project.analytic_account_id and project.allow_billable or project.allow_timesheets)): + return {} + profitability = project.get_profitability_common() + start_of_month = fields.Date.context_today(self) + relativedelta(day=1) + timesheets_this_month = self.env['project.profitability.report'].read_group( + [('project_id', '=', project.id), + ('line_date', '>=', start_of_month)], + ['project_id', + 'timesheet_unit_amount'], + ['project_id']) + timesheets_previous_month = self.env['project.profitability.report'].read_group( + [('project_id', '=', project.id), + ('line_date', '>=', start_of_month + relativedelta(months=-1, day=1)), + ('line_date', '<', start_of_month) + ], + ['project_id', + 'timesheet_unit_amount'], + ['project_id']) + timesheet_unit_amount = timesheets_this_month and timesheets_this_month[0]['timesheet_unit_amount'] or 0.0 + previous_timesheet_unit_amount = timesheets_previous_month and timesheets_previous_month[0]['timesheet_unit_amount'] or 0.0 + return { + 'allow_timesheets': project.allow_timesheets, + 'analytic_account_id': project.analytic_account_id, + 'month': start_of_month.strftime('%B %Y'), + 'previous_month': (start_of_month + relativedelta(months=-1, day=1)).strftime('%B'), + 'is_timesheet_uom_hour': self.env.company._is_timesheet_hour_uom(), + 'timesheet_uom': self.env.company._timesheet_uom_text(), + 'timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(timesheet_unit_amount), digits=0), + 'previous_timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(previous_timesheet_unit_amount), digits=0), + 'timesheet_trend': formatLang(self.env, + previous_timesheet_unit_amount > 0 and ((timesheet_unit_amount / previous_timesheet_unit_amount) - 1) * 100 or 0.0, + digits=0), + 'costs': format_amount(self.env, -profitability['costs'], self.env.company.currency_id), + 'revenues': format_amount(self.env, profitability['revenues'], self.env.company.currency_id), + 'margin': profitability['margin'], + 'margin_formatted': format_amount(self.env, profitability['margin'], self.env.company.currency_id), + 'margin_percentage': formatLang(self.env, + not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['margin'] / profitability['costs']) * 100 or 0.0, + digits=0), + 'billing_rate': formatLang(self.env, + not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['revenues'] / profitability['costs']) * 100 or 0.0, + digits=0), + } diff --git a/addons/sale_timesheet/static/src/xml/sale_project_templates.xml b/addons/sale_timesheet/static/src/xml/sale_project_templates.xml index 0801ecf264b..8447c320668 100644 --- a/addons/sale_timesheet/static/src/xml/sale_project_templates.xml +++ b/addons/sale_timesheet/static/src/xml/sale_project_templates.xml @@ -5,4 +5,39 @@ Create Sales Order + + + + + + + + Profitability + Recorded Hours + + + + See Overview + + + + + + + + + + + + + + + + + + + + diff --git a/addons/sale_timesheet/views/project_update_templates.xml b/addons/sale_timesheet/views/project_update_templates.xml new file mode 100644 index 00000000000..7858faff0a4 --- /dev/null +++ b/addons/sale_timesheet/views/project_update_templates.xml @@ -0,0 +1,47 @@ + + + + + + + Profitability + + + In , + + + + + have been recorded on the project vs. + + + + in ( %). + + + + The cost of the project is now at , for a revenue of , leading to a + + + + + + + + + margin ( %), and a % billing rate. + + + + + + + + + + + + ( % billable) + + +