diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 4da21b2a440..39f6976f3b6 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -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" diff --git a/addons/sale_timesheet/models/project_update.py b/addons/sale_timesheet/models/project_update.py index f32a0952412..467167d7c2c 100644 --- a/addons/sale_timesheet/models/project_update.py +++ b/addons/sale_timesheet/models/project_update.py @@ -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 diff --git a/addons/sale_timesheet/tests/test_project_update.py b/addons/sale_timesheet/tests/test_project_update.py index 851effda2c0..de79a04f397 100644 --- a/addons/sale_timesheet/tests/test_project_update.py +++ b/addons/sale_timesheet/tests/test_project_update.py @@ -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.") diff --git a/addons/sale_timesheet/views/project_update_templates.xml b/addons/sale_timesheet/views/project_update_templates.xml index 20173220cc0..caab333f4e8 100644 --- a/addons/sale_timesheet/views/project_update_templates.xml +++ b/addons/sale_timesheet/views/project_update_templates.xml @@ -7,13 +7,13 @@