[IMP] sale_timesheet: reduce query count on project profitability

Prior to this commit, we queried the project profitability view in order
to have the total amount of timesheeted time.

With this commit, thanks to the fact that this value was only
retrieved in the view by subquerying analytic.account.line we can only
query the analytic.account.line in order to access information.
This allows us to query the project profitability report only when
required. Otherwise, only the analytic account line table is queried.

This commit was motivated by the performance issue fixed in PR : #72641

task-2555323

closes odoo/odoo#71873

Related: odoo/enterprise#18851
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Thibault Libioulle
2021-08-23 13:21:24 +00:00
committed by Yannick Tivisse
parent 4968cc3444
commit 8f7b965446
4 changed files with 153 additions and 76 deletions
+45 -31
View File
@@ -255,9 +255,10 @@ class Project(models.Model):
def _get_profitability_items(self):
if not self.user_has_groups('project.group_project_manager'):
return {'data': []}
profitability = self.get_profitability_common()
timesheets = self.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user')
profitability = self._get_profitability_common(costs_revenues=self.allow_billable, timesheets=timesheets)
data = []
if self.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
if timesheets:
data += [{
'name': _("Timesheets"),
'value': self.env.ref('sale_timesheet.project_profitability_timesheet_panel')._render({
@@ -288,37 +289,50 @@ class Project(models.Model):
'data': data,
}
def get_profitability_common(self):
def _get_profitability_common(self, costs_revenues=True, timesheets=True):
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,
result = {
'costs': 0.0,
'margin': 0.0,
'revenues': 0.0,
'timesheet_unit_amount': 0.0,
'timesheet_percentage_billable': 0.0,
}
if costs_revenues:
profitability = self.env['project.profitability.report'].read_group(
[('project_id', '=', self.id)],
['project_id',
'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'], limit=1)
if profitability:
profitability = profitability[0]
result.update({
'costs': profitability['timesheet_cost'] + profitability['expense_cost'],
'margin': profitability['margin'],
'revenues': (profitability['amount_untaxed_invoiced'] + profitability['amount_untaxed_to_invoice'] +
profitability['expense_amount_untaxed_invoiced'] + profitability['expense_amount_untaxed_to_invoice'] +
profitability['other_revenues']),
})
if timesheets:
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']
result.update({
'timesheet_unit_amount': self._convert_project_uom_to_timesheet_encode_uom(timesheet_billable + timesheet_non_billable),
'timesheet_percentage_billable': timesheet_billable and timesheet_billable / (timesheet_billable + timesheet_non_billable) * 100 or 0.0,
})
return result
class ProjectTask(models.Model):
_inherit = "project.task"
+43 -40
View File
@@ -4,8 +4,7 @@
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
from odoo.tools import float_utils, format_amount, formatLang, format_date, DEFAULT_SERVER_DATE_FORMAT
class ProjectUpdate(models.Model):
_inherit = 'project.update'
@@ -19,46 +18,50 @@ class ProjectUpdate(models.Model):
@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)):
costs_revenues = project.analytic_account_id and project.allow_billable
timesheets = project.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user')
if not (self.user_has_groups('project.group_project_manager') and (costs_revenues or 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,
this_month = fields.Date.context_today(self) + relativedelta(day=1)
previous_month = this_month + relativedelta(months=-1, day=1)
result = {
'allow_timesheets': timesheets,
'allow_costs_and_revenues': costs_revenues,
'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'),
'month': format_date(self.env, this_month, date_format='LLLL y'),
'previous_month': format_date(self.env, previous_month, date_format='LLLL'),
'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),
'timesheet_unit_amount': '', 'previous_timesheet_unit_amount': '',
'timesheet_trend': '', 'costs': '', 'revenues': '', 'margin': '',
'margin_formatted': '', 'margin_percentage': '', 'billing_rate': '',
}
if timesheets:
timesheets_per_month = self.env['account.analytic.line'].read_group(
[('project_id', '=', project.id),
('date', '>=', previous_month)],
['date', 'unit_amount'],
['date:month'])
timesheet_unit_amount = {ts['__range']['date']['from']: ts['unit_amount'] for ts in timesheets_per_month}
this_amount = timesheet_unit_amount.get(this_month.replace(day=1).strftime(DEFAULT_SERVER_DATE_FORMAT), 0.0)
previous_amount = timesheet_unit_amount.get(previous_month.replace(day=1).strftime(DEFAULT_SERVER_DATE_FORMAT), 0.0)
result.update({
'timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(this_amount), digits=0),
'previous_timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(previous_amount), digits=0),
'timesheet_trend': formatLang(self.env, previous_amount > 0 and ((this_amount / previous_amount) - 1) * 100 or 0.0, digits=0),
})
if costs_revenues:
profitability = project._get_profitability_common(costs_revenues=True, timesheets=False)
result.update({
'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),
})
return result
@@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from dateutil.relativedelta import relativedelta
from freezegun import freeze_time
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import Form
from odoo.tools import format_date
from odoo.addons.project.tests.test_project_update_flow import TestProjectUpdate
@@ -27,9 +29,9 @@ class TestProjectUpdateSaleTimesheet(TestProjectUpdate):
self.project_pigs.allow_billable = True
template_values = self.env['project.update']._get_template_values(self.project_pigs)
today = fields.Date.context_today(self.project_pigs)
self.assertEqual(template_values['profitability']['month'], today.strftime('%B %Y'),
self.assertEqual(template_values['profitability']['month'], format_date(self.env, today, date_format='LLLL y'),
"The month used in the template should be well defined")
self.assertEqual(template_values['profitability']['previous_month'], (today + relativedelta(months=-1)).strftime('%B'),
self.assertEqual(template_values['profitability']['previous_month'], format_date(self.env, (today + relativedelta(months=-1)), date_format='LLLL'),
"The previous month used in the template should be well defined")
self.assertTrue(template_values['profitability']['is_timesheet_uom_hour'], "Default timesheet uom is hour.")
self.assertEqual(template_values['profitability']['timesheet_uom'], "hours", "Default timesheet uom should be displayed as 'hours'")
@@ -62,3 +64,61 @@ class TestProjectUpdateSaleTimesheet(TestProjectUpdate):
self.project_pigs.action_view_timesheet()
except Exception as e:
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])
def test_project_update_description_date(self):
self.project_pigs.allow_timesheets = True
self.project_pigs.allow_billable = True
self.employee_user = self.env['hr.employee'].create({
'name': 'Employee User',
'timesheet_cost': 15,
})
self.env['account.analytic.line'].create({
'project_id': self.project_pigs.id,
'task_id': self.task_1.id,
'name': 'my first timesheet',
'unit_amount': 4,
'employee_id': self.employee_user.id,
'date': '2021-07-23'
})
self.env['account.analytic.line'].create({
'project_id': self.project_pigs.id,
'task_id': self.task_1.id,
'name': 'my second timesheet',
'unit_amount': 6,
'employee_id': self.employee_user.id,
'date': '2021-08-23'
})
with freeze_time('2021-08-24'):
template_values = self.env['project.update']._get_template_values(self.project_pigs)
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '6', "Timesheet unit amount used in the template should be defined.")
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '4', "Previous Timesheet unit amount used in the template should be defined.")
def test_project_update_description_date_lang(self):
self.project_pigs.allow_timesheets = True
self.project_pigs.allow_billable = True
self.env['res.lang']._activate_lang('fr_FR')
self.env.user.write({'lang': 'fr_FR'})
self.employee_user = self.env['hr.employee'].create({
'name': 'Employee User',
'timesheet_cost': 15,
})
self.env['account.analytic.line'].create({
'project_id': self.project_pigs.id,
'task_id': self.task_1.id,
'name': 'my first timesheet',
'unit_amount': 4,
'employee_id': self.employee_user.id,
'date': '2021-07-23'
})
self.env['account.analytic.line'].create({
'project_id': self.project_pigs.id,
'task_id': self.task_1.id,
'name': 'my second timesheet',
'unit_amount': 6,
'employee_id': self.employee_user.id,
'date': '2021-08-23'
})
with freeze_time('2021-08-24'):
template_values = self.env['project.update']._get_template_values(self.project_pigs)
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '6', "Timesheet unit amount used in the template should be defined.")
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '4', "Previous Timesheet unit amount used in the template should be defined.")
@@ -7,13 +7,13 @@
<div name="profitability" t-if="profitability">
<h1>Profitability</h1>
<t t-if="project.allow_timesheets and user.has_group('hr_timesheet.group_hr_timesheet_user')">
<t t-if="profitability['allow_timesheets']">
In <t t-esc="profitability['month']"/>, <b><t t-if="profitability['is_timesheet_uom_hour']" t-esc="profitability['timesheet_unit_amount']"/><t t-else="" t-esc="profitability['timesheet_unit_amount']"/> <t t-esc="profitability['timesheet_uom']"/></b> have been recorded on the project vs.
<t t-if="profitability['is_timesheet_uom_hour']" t-esc="profitability['previous_timesheet_unit_amount']"/><t t-else="" t-esc="profitability['previous_timesheet_unit_amount']"/>
<t t-esc="profitability['timesheet_uom']"/> in <t t-esc="profitability['previous_month']"/> (<t t-esc="profitability['timesheet_trend']"/> %).
</t>
<t t-if="project.analytic_account_id and project.allow_billable and user.has_group('project.group_project_manager')">
<t t-if="profitability['allow_costs_and_revenues']">
The cost of the project is now at <t t-esc="profitability['costs']"/>, for a revenue of <t t-esc="profitability['revenues']"/>, leading to a
<span>
<font t-if="profitability['margin'] &gt; 0" style="color: rgb(0, 128, 0)">